1use crate::error::{OwlError, Result};
2use crate::manchester::{class_expression_to_turtle_fragment, parse_class_expression};
3use crate::span::{
4 all_entity_statement_ranges, entity_primary_block_range, namespaces_for_text,
5 short_name_from_iri, statement_end_byte, ByteRange,
6};
7use crate::turtle_lex::{advance_turtle_scan, turtle_literal_lexical_value, TurtleScanState};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::path::Path;
11use strixonomy_core::{read_to_string_capped, OntologyFormat, MAX_FILE_BYTES};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(tag = "op", rename_all = "snake_case")]
16pub enum PatchOp {
17 AddPrefix {
18 prefix: String,
19 namespace_iri: String,
20 },
21 RemovePrefix {
22 prefix: String,
23 },
24 SetPrefix {
25 prefix: String,
26 namespace_iri: String,
27 },
28 SetOntologyIri {
29 ontology_iri: String,
30 },
31 SetVersionIri {
32 ontology_iri: String,
33 version_iri: String,
34 },
35 AddOntologyAnnotation {
36 ontology_iri: String,
37 predicate: String,
38 value: String,
39 },
40 RemoveOntologyAnnotation {
41 ontology_iri: String,
42 predicate: String,
43 value: String,
44 },
45 CreateEntity {
46 entity_iri: String,
47 kind: PatchEntityKind,
48 },
49 DeleteEntity {
50 entity_iri: String,
51 },
52 SetLabel {
53 entity_iri: String,
54 value: String,
55 },
56 AddLabel {
57 entity_iri: String,
58 value: String,
59 },
60 RemoveLabel {
61 entity_iri: String,
62 value: String,
63 },
64 SetComment {
65 entity_iri: String,
66 value: String,
67 },
68 AddComment {
69 entity_iri: String,
70 value: String,
71 },
72 RemoveComment {
73 entity_iri: String,
74 value: String,
75 },
76 AddSubClassOf {
77 entity_iri: String,
78 parent_iri: String,
79 },
80 RemoveSubClassOf {
81 entity_iri: String,
82 parent_iri: String,
83 },
84 AddComplexSubClassOf {
85 entity_iri: String,
86 manchester: String,
87 },
88 RemoveComplexSubClassOf {
89 entity_iri: String,
90 manchester: String,
91 },
92 AddEquivalentClass {
93 entity_iri: String,
94 manchester: String,
95 },
96 RemoveEquivalentClass {
97 entity_iri: String,
98 manchester: String,
99 },
100 SetEquivalentClass {
101 entity_iri: String,
102 manchester: String,
103 },
104 SetDeprecated {
105 entity_iri: String,
106 value: bool,
107 },
108 AddDisjointClass {
109 entity_iri: String,
110 other_iri: String,
111 },
112 RemoveDisjointClass {
113 entity_iri: String,
114 other_iri: String,
115 },
116 AddImport {
117 ontology_iri: String,
118 import_iri: String,
119 },
120 RemoveImport {
121 ontology_iri: String,
122 import_iri: String,
123 },
124 AddDomain {
125 entity_iri: String,
126 class_iri: String,
127 },
128 RemoveDomain {
129 entity_iri: String,
130 class_iri: String,
131 },
132 AddRange {
133 entity_iri: String,
134 range_iri: String,
135 },
136 RemoveRange {
137 entity_iri: String,
138 range_iri: String,
139 },
140 SetFunctional {
141 entity_iri: String,
142 value: bool,
143 },
144 SetInverseFunctional {
145 entity_iri: String,
146 value: bool,
147 },
148 SetTransitive {
149 entity_iri: String,
150 value: bool,
151 },
152 SetSymmetric {
153 entity_iri: String,
154 value: bool,
155 },
156 SetAsymmetric {
157 entity_iri: String,
158 value: bool,
159 },
160 SetReflexive {
161 entity_iri: String,
162 value: bool,
163 },
164 SetIrreflexive {
165 entity_iri: String,
166 value: bool,
167 },
168 AddPropertyChain {
169 entity_iri: String,
170 properties: Vec<String>,
171 },
172 RemovePropertyChain {
173 entity_iri: String,
174 properties: Vec<String>,
175 },
176 AddClassAssertion {
177 entity_iri: String,
178 class_iri: String,
179 },
180 RemoveClassAssertion {
181 entity_iri: String,
182 class_iri: String,
183 },
184 AddObjectPropertyAssertion {
185 entity_iri: String,
186 property_iri: String,
187 target_iri: String,
188 },
189 RemoveObjectPropertyAssertion {
190 entity_iri: String,
191 property_iri: String,
192 target_iri: String,
193 },
194 AddDataPropertyAssertion {
195 entity_iri: String,
196 property_iri: String,
197 value: String,
198 },
199 RemoveDataPropertyAssertion {
200 entity_iri: String,
201 property_iri: String,
202 value: String,
203 },
204 AddAnnotation {
205 entity_iri: String,
206 predicate: String,
207 value: String,
208 },
209 RemoveAnnotation {
210 entity_iri: String,
211 predicate: String,
212 value: String,
213 },
214 AddHasKey {
216 class_iri: String,
217 properties: Vec<String>,
218 },
219 RemoveHasKey {
220 class_iri: String,
221 properties: Vec<String>,
222 },
223 AddDisjointUnion {
224 class_iri: String,
225 members: Vec<String>,
226 },
227 RemoveDisjointUnion {
228 class_iri: String,
229 members: Vec<String>,
230 },
231 AddInverseObjectProperties {
232 property_iri: String,
233 inverse_iri: String,
234 },
235 RemoveInverseObjectProperties {
236 property_iri: String,
237 inverse_iri: String,
238 },
239 AddEquivalentObjectProperties {
240 properties: Vec<String>,
241 },
242 RemoveEquivalentObjectProperties {
243 properties: Vec<String>,
244 },
245 AddDisjointObjectProperties {
246 properties: Vec<String>,
247 },
248 RemoveDisjointObjectProperties {
249 properties: Vec<String>,
250 },
251 AddEquivalentDataProperties {
252 properties: Vec<String>,
253 },
254 RemoveEquivalentDataProperties {
255 properties: Vec<String>,
256 },
257 AddDisjointDataProperties {
258 properties: Vec<String>,
259 },
260 RemoveDisjointDataProperties {
261 properties: Vec<String>,
262 },
263 AddSubObjectPropertyOf {
264 property_iri: String,
265 parent_iri: String,
266 },
267 RemoveSubObjectPropertyOf {
268 property_iri: String,
269 parent_iri: String,
270 },
271 AddSubDataPropertyOf {
272 property_iri: String,
273 parent_iri: String,
274 },
275 RemoveSubDataPropertyOf {
276 property_iri: String,
277 parent_iri: String,
278 },
279 AddNegativeObjectPropertyAssertion {
281 entity_iri: String,
282 property_iri: String,
283 target_iri: String,
284 },
285 RemoveNegativeObjectPropertyAssertion {
286 entity_iri: String,
287 property_iri: String,
288 target_iri: String,
289 },
290 AddNegativeDataPropertyAssertion {
291 entity_iri: String,
292 property_iri: String,
293 value: String,
294 },
295 RemoveNegativeDataPropertyAssertion {
296 entity_iri: String,
297 property_iri: String,
298 value: String,
299 },
300 AddSameIndividual {
301 individuals: Vec<String>,
302 },
303 RemoveSameIndividual {
304 individuals: Vec<String>,
305 },
306 AddDifferentIndividuals {
307 individuals: Vec<String>,
308 },
309 RemoveDifferentIndividuals {
310 individuals: Vec<String>,
311 },
312 AddDatatypeDefinition {
314 datatype_iri: String,
315 manchester: String,
316 },
317 RemoveDatatypeDefinition {
318 datatype_iri: String,
319 manchester: String,
320 },
321 AddAxiomAnnotation {
323 axiom_op: String,
324 subject_iri: String,
325 related_iri: Option<String>,
326 predicate: String,
327 value: String,
328 },
329 RemoveAxiomAnnotation {
330 axiom_op: String,
331 subject_iri: String,
332 related_iri: Option<String>,
333 predicate: String,
334 value: String,
335 },
336 AddSwrlRule {
338 ontology_iri: String,
339 rule_json: String,
340 },
341 RemoveSwrlRule {
342 ontology_iri: String,
343 rule_json: String,
344 },
345 ReplaceSwrlRule {
346 ontology_iri: String,
347 old_rule_json: String,
348 new_rule_json: String,
349 },
350}
351
352#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
353#[serde(rename_all = "snake_case")]
354pub enum PatchEntityKind {
355 Class,
356 ObjectProperty,
357 DataProperty,
358 AnnotationProperty,
359 Individual,
360 Datatype,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct PatchDiagnostic {
365 pub severity: String,
366 pub message: String,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct ApplyPatchResult {
371 pub applied: bool,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub preview_text: Option<String>,
374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
375 pub diagnostics: Vec<PatchDiagnostic>,
376 #[serde(skip_serializing_if = "Option::is_none")]
377 pub document_path: Option<String>,
378}
379
380pub fn apply_patches(
382 document_path: &Path,
383 patches: &[PatchOp],
384 preview_only: bool,
385 namespaces: &BTreeMap<String, String>,
386) -> Result<ApplyPatchResult> {
387 let format = OntologyFormat::from_extension(
388 document_path.extension().and_then(|e| e.to_str()).unwrap_or(""),
389 );
390 match format {
391 OntologyFormat::Turtle => {}
392 OntologyFormat::Owl | OntologyFormat::RdfXml | OntologyFormat::OwlXml => {
393 return crate::apply_xml::apply_xml_patches(
394 document_path,
395 patches,
396 preview_only,
397 namespaces,
398 );
399 }
400 other => {
401 return Err(OwlError::UnsupportedFormat(format!(
402 "write-back supports Turtle (.ttl), RDF/XML (.owl/.rdf), OWL/XML (.owx); got {}",
403 other.as_str()
404 )));
405 }
406 }
407
408 let source = read_to_string_capped(document_path, MAX_FILE_BYTES).map_err(OwlError::Core)?;
409 let mut result = apply_patches_to_text(&source, patches, preview_only, namespaces)?;
410 result.document_path = Some(document_path.display().to_string());
411
412 if result.applied && !preview_only {
413 if let Some(text) = &result.preview_text {
414 atomic_write(document_path, text)?;
415 }
416 }
417 Ok(result)
418}
419
420pub fn atomic_write(path: &Path, contents: &str) -> Result<()> {
422 Ok(strixonomy_core::atomic_write(path, contents)?)
423}
424
425pub fn apply_patches_to_text(
427 source: &str,
428 patches: &[PatchOp],
429 preview_only: bool,
430 namespaces: &BTreeMap<String, String>,
431) -> Result<ApplyPatchResult> {
432 let mut working = source.to_string();
433 let mut diagnostics = Vec::new();
434
435 for patch in patches {
436 match apply_one_patch(&mut working, patch, namespaces) {
437 Ok(()) => {}
438 Err(e) => {
439 diagnostics.push(PatchDiagnostic {
440 severity: "error".to_string(),
441 message: e.to_string(),
442 });
443 return Ok(ApplyPatchResult {
444 applied: false,
445 preview_text: Some(source.to_string()),
446 diagnostics,
447 document_path: None,
448 });
449 }
450 }
451 }
452
453 let changed = working != source;
454 Ok(ApplyPatchResult {
455 applied: changed && !preview_only,
456 preview_text: if changed { Some(working) } else { None },
457 diagnostics,
458 document_path: None,
459 })
460}
461
462fn apply_one_patch(
463 text: &mut String,
464 patch: &PatchOp,
465 namespaces: &BTreeMap<String, String>,
466) -> Result<()> {
467 match patch {
468 PatchOp::AddPrefix { prefix, namespace_iri } => add_prefix(text, prefix, namespace_iri),
469 PatchOp::RemovePrefix { prefix } => remove_prefix(text, prefix),
470 PatchOp::SetPrefix { prefix, namespace_iri } => set_prefix(text, prefix, namespace_iri),
471 PatchOp::SetOntologyIri { ontology_iri } => set_ontology_iri(text, ontology_iri),
472 PatchOp::SetVersionIri { ontology_iri, version_iri } => {
473 remove_all_predicate_any_statement(text, ontology_iri, "owl:versionIRI", namespaces)?;
474 let version_term = iri_to_turtle_term(version_iri, namespaces)?;
475 add_object_triple(text, ontology_iri, "owl:versionIRI", &version_term, namespaces)
476 }
477 PatchOp::AddOntologyAnnotation { ontology_iri, predicate, value } => {
478 add_annotation_value(text, ontology_iri, predicate, value, namespaces)
479 }
480 PatchOp::RemoveOntologyAnnotation { ontology_iri, predicate, value } => {
481 remove_annotation_value(text, ontology_iri, predicate, value, namespaces)
482 }
483 PatchOp::CreateEntity { entity_iri, kind } => {
484 create_entity(text, entity_iri, *kind, namespaces)
485 }
486 PatchOp::DeleteEntity { entity_iri } => delete_entity(text, entity_iri, namespaces),
487 PatchOp::SetLabel { entity_iri, value } => {
488 remove_all_predicate_any_statement(text, entity_iri, "rdfs:label", namespaces)?;
489 add_annotation_triple(text, entity_iri, "rdfs:label", value, namespaces)
490 }
491 PatchOp::AddLabel { entity_iri, value } => {
492 add_annotation_triple(text, entity_iri, "rdfs:label", value, namespaces)
493 }
494 PatchOp::RemoveLabel { entity_iri, value } => {
495 remove_matching_predicate_any(text, entity_iri, "rdfs:label", value, namespaces)
496 }
497 PatchOp::SetComment { entity_iri, value } => {
498 remove_all_predicate_any_statement(text, entity_iri, "rdfs:comment", namespaces)?;
499 add_annotation_triple(text, entity_iri, "rdfs:comment", value, namespaces)
500 }
501 PatchOp::AddComment { entity_iri, value } => {
502 add_annotation_triple(text, entity_iri, "rdfs:comment", value, namespaces)
503 }
504 PatchOp::RemoveComment { entity_iri, value } => {
505 remove_matching_predicate_any(text, entity_iri, "rdfs:comment", value, namespaces)
506 }
507 PatchOp::AddSubClassOf { entity_iri, parent_iri } => {
508 add_subclass_triple(text, entity_iri, parent_iri, namespaces)
509 }
510 PatchOp::RemoveSubClassOf { entity_iri, parent_iri } => {
511 remove_predicate_iri_object(text, entity_iri, "rdfs:subClassOf", parent_iri, namespaces)
512 }
513 PatchOp::AddComplexSubClassOf { entity_iri, manchester } => {
514 add_complex_axiom(text, entity_iri, manchester, "rdfs:subClassOf", namespaces)
515 }
516 PatchOp::RemoveComplexSubClassOf { entity_iri, manchester } => {
517 remove_complex_axiom(text, entity_iri, manchester, "rdfs:subClassOf", namespaces)
518 }
519 PatchOp::AddEquivalentClass { entity_iri, manchester } => {
520 add_complex_axiom(text, entity_iri, manchester, "owl:equivalentClass", namespaces)
521 }
522 PatchOp::RemoveEquivalentClass { entity_iri, manchester } => {
523 remove_complex_axiom(text, entity_iri, manchester, "owl:equivalentClass", namespaces)
524 }
525 PatchOp::SetEquivalentClass { entity_iri, manchester } => {
526 remove_predicate_triples(text, entity_iri, "owl:equivalentClass", namespaces)?;
527 add_complex_axiom(text, entity_iri, manchester, "owl:equivalentClass", namespaces)
528 }
529 PatchOp::SetDeprecated { entity_iri, value } => {
530 if *value {
531 add_object_triple(text, entity_iri, "owl:deprecated", "true", namespaces)
532 } else {
533 remove_predicate_triples(text, entity_iri, "owl:deprecated", namespaces)
534 }
535 }
536 PatchOp::AddDisjointClass { entity_iri, other_iri } => {
537 let other = iri_to_turtle_term(other_iri, namespaces)?;
538 add_object_triple(text, entity_iri, "owl:disjointWith", &other, namespaces)
539 }
540 PatchOp::RemoveDisjointClass { entity_iri, other_iri } => {
541 remove_predicate_iri_object(text, entity_iri, "owl:disjointWith", other_iri, namespaces)
542 }
543 PatchOp::AddImport { ontology_iri, import_iri } => {
544 let import_term = iri_to_turtle_term(import_iri, namespaces)?;
545 add_object_triple(text, ontology_iri, "owl:imports", &import_term, namespaces)
546 }
547 PatchOp::RemoveImport { ontology_iri, import_iri } => {
548 remove_predicate_iri_object(text, ontology_iri, "owl:imports", import_iri, namespaces)
549 }
550 PatchOp::AddDomain { entity_iri, class_iri } => {
551 let class = iri_to_turtle_term(class_iri, namespaces)?;
552 add_object_triple(text, entity_iri, "rdfs:domain", &class, namespaces)
553 }
554 PatchOp::RemoveDomain { entity_iri, class_iri } => {
555 remove_predicate_iri_object(text, entity_iri, "rdfs:domain", class_iri, namespaces)
556 }
557 PatchOp::AddRange { entity_iri, range_iri } => {
558 let range = iri_to_turtle_term(range_iri, namespaces)?;
559 add_object_triple(text, entity_iri, "rdfs:range", &range, namespaces)
560 }
561 PatchOp::RemoveRange { entity_iri, range_iri } => {
562 remove_predicate_iri_object(text, entity_iri, "rdfs:range", range_iri, namespaces)
563 }
564 PatchOp::SetFunctional { entity_iri, value } => set_property_characteristic(
565 text,
566 entity_iri,
567 "owl:FunctionalProperty",
568 *value,
569 namespaces,
570 ),
571 PatchOp::SetInverseFunctional { entity_iri, value } => set_property_characteristic(
572 text,
573 entity_iri,
574 "owl:InverseFunctionalProperty",
575 *value,
576 namespaces,
577 ),
578 PatchOp::SetTransitive { entity_iri, value } => set_property_characteristic(
579 text,
580 entity_iri,
581 "owl:TransitiveProperty",
582 *value,
583 namespaces,
584 ),
585 PatchOp::SetSymmetric { entity_iri, value } => set_property_characteristic(
586 text,
587 entity_iri,
588 "owl:SymmetricProperty",
589 *value,
590 namespaces,
591 ),
592 PatchOp::SetAsymmetric { entity_iri, value } => set_property_characteristic(
593 text,
594 entity_iri,
595 "owl:AsymmetricProperty",
596 *value,
597 namespaces,
598 ),
599 PatchOp::SetReflexive { entity_iri, value } => set_property_characteristic(
600 text,
601 entity_iri,
602 "owl:ReflexiveProperty",
603 *value,
604 namespaces,
605 ),
606 PatchOp::SetIrreflexive { entity_iri, value } => set_property_characteristic(
607 text,
608 entity_iri,
609 "owl:IrreflexiveProperty",
610 *value,
611 namespaces,
612 ),
613 PatchOp::AddPropertyChain { entity_iri, properties } => {
614 add_property_chain(text, entity_iri, properties, namespaces)
615 }
616 PatchOp::RemovePropertyChain { entity_iri, properties } => {
617 remove_property_chain(text, entity_iri, properties, namespaces)
618 }
619 PatchOp::AddClassAssertion { entity_iri, class_iri } => {
620 let class = iri_to_turtle_term(class_iri, namespaces)?;
621 add_type_triple(text, entity_iri, &class, namespaces)
622 }
623 PatchOp::RemoveClassAssertion { entity_iri, class_iri } => {
624 remove_rdf_type_iri(text, entity_iri, class_iri, namespaces)
625 }
626 PatchOp::AddObjectPropertyAssertion { entity_iri, property_iri, target_iri } => {
627 let prop = iri_to_turtle_term(property_iri, namespaces)?;
628 let target = iri_to_turtle_term(target_iri, namespaces)?;
629 add_property_assertion_triple(text, entity_iri, &prop, &target, namespaces)
630 }
631 PatchOp::RemoveObjectPropertyAssertion { entity_iri, property_iri, target_iri } => {
632 let prop = iri_to_turtle_term(property_iri, namespaces)?;
633 remove_predicate_iri_object(text, entity_iri, &prop, target_iri, namespaces)
634 }
635 PatchOp::AddDataPropertyAssertion { entity_iri, property_iri, value } => {
636 let prop = iri_to_turtle_term(property_iri, namespaces)?;
637 add_data_property_assertion(text, entity_iri, &prop, value, namespaces)
638 }
639 PatchOp::RemoveDataPropertyAssertion { entity_iri, property_iri, value } => {
640 let prop = iri_to_turtle_term(property_iri, namespaces)?;
641 remove_matching_predicate_any(text, entity_iri, &prop, value, namespaces)
642 }
643 PatchOp::AddAnnotation { entity_iri, predicate, value } => {
644 add_annotation_value(text, entity_iri, predicate, value, namespaces)
645 }
646 PatchOp::RemoveAnnotation { entity_iri, predicate, value } => {
647 remove_annotation_value(text, entity_iri, predicate, value, namespaces)
648 }
649 PatchOp::AddHasKey { class_iri, properties } => {
650 add_iri_list_axiom(text, class_iri, "owl:hasKey", properties, namespaces)
651 }
652 PatchOp::RemoveHasKey { class_iri, properties } => {
653 remove_iri_list_axiom(text, class_iri, "owl:hasKey", properties, namespaces)
654 }
655 PatchOp::AddDisjointUnion { class_iri, members } => {
656 add_iri_list_axiom(text, class_iri, "owl:disjointUnionOf", members, namespaces)
657 }
658 PatchOp::RemoveDisjointUnion { class_iri, members } => {
659 remove_iri_list_axiom(text, class_iri, "owl:disjointUnionOf", members, namespaces)
660 }
661 PatchOp::AddInverseObjectProperties { property_iri, inverse_iri } => {
662 let inv = iri_to_turtle_term(inverse_iri, namespaces)?;
663 add_object_triple(text, property_iri, "owl:inverseOf", &inv, namespaces)
664 }
665 PatchOp::RemoveInverseObjectProperties { property_iri, inverse_iri } => {
666 remove_predicate_iri_object(
667 text,
668 property_iri,
669 "owl:inverseOf",
670 inverse_iri,
671 namespaces,
672 )
673 }
674 PatchOp::AddEquivalentObjectProperties { properties } => {
675 add_pairwise_property_axioms(text, properties, "owl:equivalentProperty", namespaces)
676 }
677 PatchOp::RemoveEquivalentObjectProperties { properties } => {
678 remove_pairwise_property_axioms(text, properties, "owl:equivalentProperty", namespaces)
679 }
680 PatchOp::AddDisjointObjectProperties { properties } => {
681 add_pairwise_property_axioms(text, properties, "owl:propertyDisjointWith", namespaces)
682 }
683 PatchOp::RemoveDisjointObjectProperties { properties } => remove_pairwise_property_axioms(
684 text,
685 properties,
686 "owl:propertyDisjointWith",
687 namespaces,
688 ),
689 PatchOp::AddEquivalentDataProperties { properties } => {
690 add_pairwise_property_axioms(text, properties, "owl:equivalentProperty", namespaces)
691 }
692 PatchOp::RemoveEquivalentDataProperties { properties } => {
693 remove_pairwise_property_axioms(text, properties, "owl:equivalentProperty", namespaces)
694 }
695 PatchOp::AddDisjointDataProperties { properties } => {
696 add_pairwise_property_axioms(text, properties, "owl:propertyDisjointWith", namespaces)
697 }
698 PatchOp::RemoveDisjointDataProperties { properties } => remove_pairwise_property_axioms(
699 text,
700 properties,
701 "owl:propertyDisjointWith",
702 namespaces,
703 ),
704 PatchOp::AddSubObjectPropertyOf { property_iri, parent_iri } => {
705 let parent = iri_to_turtle_term(parent_iri, namespaces)?;
706 add_object_triple(text, property_iri, "rdfs:subPropertyOf", &parent, namespaces)
707 }
708 PatchOp::RemoveSubObjectPropertyOf { property_iri, parent_iri } => {
709 remove_predicate_iri_object(
710 text,
711 property_iri,
712 "rdfs:subPropertyOf",
713 parent_iri,
714 namespaces,
715 )
716 }
717 PatchOp::AddSubDataPropertyOf { property_iri, parent_iri } => {
718 let parent = iri_to_turtle_term(parent_iri, namespaces)?;
719 add_object_triple(text, property_iri, "rdfs:subPropertyOf", &parent, namespaces)
720 }
721 PatchOp::RemoveSubDataPropertyOf { property_iri, parent_iri } => {
722 remove_predicate_iri_object(
723 text,
724 property_iri,
725 "rdfs:subPropertyOf",
726 parent_iri,
727 namespaces,
728 )
729 }
730 PatchOp::AddNegativeObjectPropertyAssertion { entity_iri, property_iri, target_iri } => {
731 add_negative_object_property_assertion(
732 text,
733 entity_iri,
734 property_iri,
735 target_iri,
736 namespaces,
737 )
738 }
739 PatchOp::RemoveNegativeObjectPropertyAssertion { entity_iri, property_iri, target_iri } => {
740 remove_negative_object_property_assertion(
741 text,
742 entity_iri,
743 property_iri,
744 target_iri,
745 namespaces,
746 )
747 }
748 PatchOp::AddNegativeDataPropertyAssertion { entity_iri, property_iri, value } => {
749 add_negative_data_property_assertion(text, entity_iri, property_iri, value, namespaces)
750 }
751 PatchOp::RemoveNegativeDataPropertyAssertion { entity_iri, property_iri, value } => {
752 remove_negative_data_property_assertion(
753 text,
754 entity_iri,
755 property_iri,
756 value,
757 namespaces,
758 )
759 }
760 PatchOp::AddSameIndividual { individuals } => {
761 add_pairwise_individual_axioms(text, individuals, "owl:sameAs", namespaces)
762 }
763 PatchOp::RemoveSameIndividual { individuals } => {
764 remove_pairwise_individual_axioms(text, individuals, "owl:sameAs", namespaces)
765 }
766 PatchOp::AddDifferentIndividuals { individuals } => {
767 add_different_individuals(text, individuals, namespaces)
768 }
769 PatchOp::RemoveDifferentIndividuals { individuals } => {
770 remove_different_individuals(text, individuals, namespaces)
771 }
772 PatchOp::AddDatatypeDefinition { datatype_iri, manchester } => {
773 add_datatype_definition(text, datatype_iri, manchester, namespaces)
774 }
775 PatchOp::RemoveDatatypeDefinition { datatype_iri, manchester } => {
776 remove_datatype_definition(text, datatype_iri, manchester, namespaces)
777 }
778 PatchOp::AddAxiomAnnotation { axiom_op, subject_iri, related_iri, predicate, value } => {
779 add_axiom_annotation(
780 text,
781 axiom_op,
782 subject_iri,
783 related_iri.as_deref(),
784 predicate,
785 value,
786 namespaces,
787 )
788 }
789 PatchOp::RemoveAxiomAnnotation { axiom_op, subject_iri, related_iri, predicate, value } => {
790 remove_axiom_annotation(
791 text,
792 axiom_op,
793 subject_iri,
794 related_iri.as_deref(),
795 predicate,
796 value,
797 namespaces,
798 )
799 }
800 PatchOp::AddSwrlRule { ontology_iri, rule_json } => {
801 add_swrl_rule_json(text, ontology_iri, rule_json)
802 }
803 PatchOp::RemoveSwrlRule { ontology_iri, rule_json } => {
804 remove_swrl_rule_json(text, ontology_iri, rule_json)
805 }
806 PatchOp::ReplaceSwrlRule { ontology_iri, old_rule_json, new_rule_json } => {
807 remove_swrl_rule_json(text, ontology_iri, old_rule_json)?;
808 add_swrl_rule_json(text, ontology_iri, new_rule_json)
809 }
810 }
811}
812
813const SWRL_RULE_PRED: &str = "http://ontocode.dev/ns#swrlRule";
814
815fn add_swrl_rule_json(text: &mut String, ontology_iri: &str, rule_json: &str) -> Result<()> {
816 if !is_safe_iri(ontology_iri) {
817 return Err(OwlError::PatchInvalid(format!(
818 "SWRL ontology IRI is unsafe for Turtle write-back: {ontology_iri:?}"
819 )));
820 }
821 let compact = serde_json::from_str::<serde_json::Value>(rule_json)
823 .map_err(|e| OwlError::PatchInvalid(format!("invalid SWRL rule JSON: {e}")))
824 .and_then(|v| {
825 serde_json::to_string(&v)
826 .map_err(|e| OwlError::PatchInvalid(format!("serialize SWRL rule JSON: {e}")))
827 })?;
828 let escaped = escape_turtle_string(&compact);
829 let triple = format!(
830 "<{ontology_iri}> <{SWRL_RULE_PRED}> \"{escaped}\"^^<http://www.w3.org/2001/XMLSchema#string> .\n"
831 );
832 let needle = format!("<{ontology_iri}> <{SWRL_RULE_PRED}> \"{escaped}\"");
833 if text.lines().any(|line| line.contains(&needle)) {
834 return Ok(());
835 }
836 if !text.ends_with('\n') {
837 text.push('\n');
838 }
839 text.push_str(&triple);
840 Ok(())
841}
842
843fn remove_swrl_rule_json(text: &mut String, ontology_iri: &str, rule_json: &str) -> Result<()> {
844 if !is_safe_iri(ontology_iri) {
845 return Err(OwlError::PatchInvalid(format!(
846 "SWRL ontology IRI is unsafe for Turtle write-back: {ontology_iri:?}"
847 )));
848 }
849 let target = serde_json::from_str::<serde_json::Value>(rule_json).ok();
850 let mut out = String::new();
851 let mut removed = false;
852 for line in text.lines() {
853 if !removed {
854 if let Some(literal) = swrl_rule_literal_on_line(line, ontology_iri) {
855 let matches = match (&target, serde_json::from_str::<serde_json::Value>(&literal)) {
856 (Some(want), Ok(got)) => want == &got,
857 _ => {
858 let compact = serde_json::from_str::<serde_json::Value>(rule_json)
860 .map(|v| {
861 serde_json::to_string(&v).unwrap_or_else(|_| rule_json.to_string())
862 })
863 .unwrap_or_else(|_| rule_json.to_string());
864 literal == rule_json || literal == compact
865 }
866 };
867 if matches {
868 removed = true;
869 continue;
870 }
871 }
872 }
873 out.push_str(line);
874 out.push('\n');
875 }
876 if !removed {
877 return Err(OwlError::PatchInvalid(format!(
878 "SWRL rule not found for ontology {ontology_iri}"
879 )));
880 }
881 *text = out;
882 Ok(())
883}
884
885fn swrl_rule_literal_on_line(line: &str, ontology_iri: &str) -> Option<String> {
887 let subj = format!("<{ontology_iri}>");
888 let pred = format!("<{SWRL_RULE_PRED}>");
889 let trimmed = line.trim();
890 if !(trimmed.contains(&subj) && trimmed.contains(&pred)) {
891 return None;
892 }
893 let after_pred = trimmed.split(&pred).nth(1)?.trim_start();
894 if !after_pred.starts_with('"') {
895 return None;
896 }
897 let mut out = String::new();
898 let bytes = after_pred.as_bytes();
899 let mut i = 1usize;
900 while i < bytes.len() {
901 match bytes[i] {
902 b'\\' if i + 1 < bytes.len() => {
903 match bytes[i + 1] {
904 b'"' => out.push('"'),
905 b'\\' => out.push('\\'),
906 b'n' => out.push('\n'),
907 b't' => out.push('\t'),
908 c => {
909 out.push('\\');
910 out.push(c as char);
911 }
912 }
913 i += 2;
914 }
915 b'"' => return Some(out),
916 c => {
917 out.push(c as char);
918 i += 1;
919 }
920 }
921 }
922 None
923}
924
925fn prefix_declaration_name(line: &str) -> Option<&str> {
926 let mut parts = line.split_whitespace();
927 let keyword = parts.next()?;
928 if !(keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")) {
929 return None;
930 }
931 parts.next()?.strip_suffix(':')
932}
933
934fn prefix_declaration_keyword(line: &str) -> Option<&str> {
935 let keyword = line.split_whitespace().next()?;
936 if keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX") {
937 Some(keyword)
938 } else {
939 None
940 }
941}
942
943fn format_prefix_declaration(keyword: &str, prefix: &str, namespace_iri: &str) -> String {
944 if keyword.eq_ignore_ascii_case("PREFIX") && !keyword.starts_with('@') {
945 format!("PREFIX {prefix}: <{namespace_iri}>")
946 } else {
947 format!("{keyword} {prefix}: <{namespace_iri}> .")
948 }
949}
950
951pub fn validate_prefix(prefix: &str, namespace_iri: &str) -> Result<()> {
952 if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
953 return Err(OwlError::PatchInvalid(format!(
954 "prefix must contain only letters, numbers, or underscores: {prefix:?}"
955 )));
956 }
957 if !(namespace_iri.starts_with("http://") || namespace_iri.starts_with("https://"))
958 || !is_safe_iri(namespace_iri)
959 {
960 return Err(OwlError::PatchInvalid(format!(
961 "prefix namespace IRI must be a valid http(s) IRI: {namespace_iri:?}"
962 )));
963 }
964 Ok(())
965}
966
967fn add_prefix(text: &mut String, prefix: &str, namespace_iri: &str) -> Result<()> {
968 validate_prefix(prefix, namespace_iri)?;
969 if text.lines().any(|line| prefix_declaration_name(line) == Some(prefix)) {
970 return Err(OwlError::PatchInvalid(format!("duplicate prefix already present: {prefix}")));
971 }
972
973 let mut offset = 0;
974 let mut insertion_at = 0;
975 for line in text.split_inclusive('\n') {
976 offset += line.len();
977 if prefix_declaration_name(line).is_some() {
978 insertion_at = offset;
979 }
980 }
981
982 let declaration = format!("@prefix {prefix}: <{namespace_iri}> .\n");
983 if insertion_at > 0 && !text[..insertion_at].ends_with('\n') {
984 text.insert_str(insertion_at, &format!("\n{declaration}"));
985 } else {
986 text.insert_str(insertion_at, &declaration);
987 }
988 Ok(())
989}
990
991fn remove_prefix(text: &mut String, prefix: &str) -> Result<()> {
992 if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
993 return Err(OwlError::PatchInvalid(format!(
994 "prefix must contain only letters, numbers, or underscores: {prefix:?}"
995 )));
996 }
997 let mut rewritten = String::with_capacity(text.len());
998 for line in text.split_inclusive('\n') {
999 if prefix_declaration_name(line) != Some(prefix) {
1000 rewritten.push_str(line);
1001 }
1002 }
1003 *text = rewritten;
1004 Ok(())
1005}
1006
1007fn set_prefix(text: &mut String, prefix: &str, namespace_iri: &str) -> Result<()> {
1008 validate_prefix(prefix, namespace_iri)?;
1009 let mut offset = 0;
1010 for line in text.split_inclusive('\n') {
1011 if prefix_declaration_name(line) == Some(prefix) {
1012 let keyword = prefix_declaration_keyword(line).unwrap_or("@prefix");
1013 let mut replacement = format_prefix_declaration(keyword, prefix, namespace_iri);
1014 if line.ends_with('\n') {
1015 replacement.push('\n');
1016 }
1017 text.replace_range(offset..offset + line.len(), &replacement);
1018 return Ok(());
1019 }
1020 offset += line.len();
1021 }
1022 add_prefix(text, prefix, namespace_iri)
1023}
1024
1025fn set_ontology_iri(text: &mut String, ontology_iri: &str) -> Result<()> {
1026 if !is_safe_iri(ontology_iri) {
1027 return Err(OwlError::PatchInvalid(format!(
1028 "IRI contains characters that cannot be safely written to Turtle: {ontology_iri:?}"
1029 )));
1030 }
1031
1032 let mut offset = 0;
1033 let mut declaration_subject = None;
1034 for line in text.split_inclusive('\n') {
1035 let leading = line.len() - line.trim_start().len();
1036 let trimmed = line.trim_start();
1037 if let Some(len) = ontology_declaration_subject_len(trimmed) {
1038 declaration_subject = Some((offset + leading, len));
1039 break;
1040 }
1041 offset += line.len();
1042 }
1043 if let Some((start, len)) = declaration_subject {
1044 text.replace_range(start..start + len, &format!("<{ontology_iri}>"));
1045 return Ok(());
1046 }
1047
1048 if !text.is_empty() && !text.ends_with('\n') {
1049 text.push('\n');
1050 }
1051 if !text.is_empty() && !text.ends_with("\n\n") {
1052 text.push('\n');
1053 }
1054 text.push_str(&format!("<{ontology_iri}> a owl:Ontology .\n"));
1055 Ok(())
1056}
1057
1058fn ontology_declaration_subject_len(trimmed: &str) -> Option<usize> {
1063 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('@') {
1064 return None;
1065 }
1066 if trimmed.len() >= 6 && trimmed[..6].eq_ignore_ascii_case("prefix") {
1068 return None;
1069 }
1070
1071 let (subject, remainder) = if trimmed.starts_with('<') {
1072 let end = trimmed.find('>')?;
1073 (&trimmed[..=end], &trimmed[end + 1..])
1074 } else {
1075 let end = trimmed.find(char::is_whitespace)?;
1076 let subject = &trimmed[..end];
1077 if !subject.contains(':') {
1079 return None;
1080 }
1081 (subject, &trimmed[end..])
1082 };
1083
1084 if subject.is_empty() {
1085 return None;
1086 }
1087 if remainder_declares_owl_ontology(remainder) {
1088 Some(subject.len())
1089 } else {
1090 None
1091 }
1092}
1093
1094fn remainder_declares_owl_ontology(remainder: &str) -> bool {
1095 let r = remainder.trim_start();
1096 for pred in ["rdf:type", "a"] {
1097 let Some(after_pred) = r.strip_prefix(pred) else {
1098 continue;
1099 };
1100 if !after_pred.starts_with(|c: char| c.is_whitespace()) {
1101 continue;
1102 }
1103 let obj = after_pred.trim_start();
1104 for ontology_type in ["owl:Ontology", "<http://www.w3.org/2002/07/owl#Ontology>"] {
1105 if let Some(after) = obj.strip_prefix(ontology_type) {
1106 if after.is_empty()
1107 || after
1108 .starts_with(|c: char| c.is_whitespace() || matches!(c, ';' | '.' | ','))
1109 {
1110 return true;
1111 }
1112 }
1113 }
1114 }
1115 false
1116}
1117
1118fn add_annotation_value(
1119 text: &mut String,
1120 entity_iri: &str,
1121 predicate: &str,
1122 value: &str,
1123 namespaces: &BTreeMap<String, String>,
1124) -> Result<()> {
1125 if predicate == "rdfs:label" || predicate.ends_with("#label") {
1126 add_annotation_triple(text, entity_iri, "rdfs:label", value, namespaces)
1127 } else if predicate == "rdfs:comment" || predicate.ends_with("#comment") {
1128 add_annotation_triple(text, entity_iri, "rdfs:comment", value, namespaces)
1129 } else if let Some(obj) = explicit_iri_annotation_term(value, namespaces)? {
1130 let pred = predicate_to_term(predicate, namespaces)?;
1131 add_object_triple(text, entity_iri, &pred, &obj, namespaces)
1132 } else {
1133 let pred = predicate_to_term(predicate, namespaces)?;
1134 add_annotation_triple(text, entity_iri, &pred, value, namespaces)
1135 }
1136}
1137
1138fn remove_annotation_value(
1139 text: &mut String,
1140 entity_iri: &str,
1141 predicate: &str,
1142 value: &str,
1143 namespaces: &BTreeMap<String, String>,
1144) -> Result<()> {
1145 if predicate == "rdfs:label" || predicate.ends_with("#label") {
1146 remove_matching_predicate_any(text, entity_iri, "rdfs:label", value, namespaces)
1147 } else if predicate == "rdfs:comment" || predicate.ends_with("#comment") {
1148 remove_matching_predicate_any(text, entity_iri, "rdfs:comment", value, namespaces)
1149 } else if let Some(obj) = explicit_iri_annotation_term(value, namespaces)? {
1150 let pred = predicate_to_term(predicate, namespaces)?;
1151 remove_predicate_object_any_statement(text, entity_iri, &pred, &obj, namespaces)
1152 } else {
1153 let pred = predicate_to_term(predicate, namespaces)?;
1154 remove_matching_predicate_any(text, entity_iri, &pred, value, namespaces)
1155 }
1156}
1157
1158fn explicit_iri_annotation_term(
1161 value: &str,
1162 namespaces: &BTreeMap<String, String>,
1163) -> Result<Option<String>> {
1164 let trimmed = value.trim();
1165 if let Some(inner) = trimmed.strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
1166 let iri = inner.trim();
1167 if iri.is_empty() {
1168 return Err(OwlError::PatchInvalid("empty IRI in annotation value <>".to_string()));
1169 }
1170 return Ok(Some(iri_to_turtle_term(iri, namespaces)?));
1171 }
1172 if let Some(curie) = known_curie_term(trimmed, namespaces) {
1173 return Ok(Some(curie.to_string()));
1174 }
1175 Ok(None)
1176}
1177
1178fn create_entity(
1179 text: &mut String,
1180 entity_iri: &str,
1181 kind: PatchEntityKind,
1182 namespaces: &BTreeMap<String, String>,
1183) -> Result<()> {
1184 if text_contains_entity(text, entity_iri, namespaces) {
1185 return Err(OwlError::EntityExists(entity_iri.to_string()));
1186 }
1187 let subject = iri_to_turtle_term(entity_iri, namespaces)?;
1188 let type_term = match kind {
1189 PatchEntityKind::Class => "owl:Class",
1190 PatchEntityKind::ObjectProperty => "owl:ObjectProperty",
1191 PatchEntityKind::DataProperty => "owl:DatatypeProperty",
1192 PatchEntityKind::AnnotationProperty => "owl:AnnotationProperty",
1193 PatchEntityKind::Individual => "owl:NamedIndividual",
1194 PatchEntityKind::Datatype => "rdfs:Datatype",
1195 };
1196 let block = format!("\n{subject} a {type_term} .\n");
1197 if !text.ends_with('\n') {
1198 text.push('\n');
1199 }
1200 text.push_str(&block);
1201 Ok(())
1202}
1203
1204fn delete_entity(
1205 text: &mut String,
1206 entity_iri: &str,
1207 namespaces: &BTreeMap<String, String>,
1208) -> Result<()> {
1209 let namespaces = crate::span::namespaces_for_text(text, namespaces);
1210 let short = short_name_from_iri(entity_iri);
1211 let mut ranges = all_entity_statement_ranges(text, entity_iri, &short, &namespaces);
1212 if ranges.is_empty() {
1213 return Err(OwlError::EntityNotFound(entity_iri.to_string()));
1214 }
1215 ranges.sort_by_key(|r| r.start);
1216 for range in ranges.into_iter().rev() {
1217 replace_range(text, range, "");
1218 }
1219 Ok(())
1220}
1221
1222fn add_annotation_triple(
1223 text: &mut String,
1224 entity_iri: &str,
1225 predicate: &str,
1226 value: &str,
1227 namespaces: &BTreeMap<String, String>,
1228) -> Result<()> {
1229 if !text_contains_entity(text, entity_iri, namespaces) {
1230 return Err(OwlError::EntityNotFound(entity_iri.to_string()));
1231 }
1232 let escaped = escape_turtle_string(value);
1233 let triple = format!(" {predicate} \"{escaped}\" ;\n");
1234 insert_into_entity_block(text, entity_iri, &triple, namespaces, true)
1235}
1236
1237fn add_object_triple(
1238 text: &mut String,
1239 entity_iri: &str,
1240 predicate: &str,
1241 object: &str,
1242 namespaces: &BTreeMap<String, String>,
1243) -> Result<()> {
1244 if !text_contains_entity(text, entity_iri, namespaces) {
1245 return Err(OwlError::EntityNotFound(entity_iri.to_string()));
1246 }
1247 let triple = format!(" {predicate} {object} ;\n");
1248 insert_into_entity_block(text, entity_iri, &triple, namespaces, false)
1249}
1250
1251fn add_complex_axiom(
1252 text: &mut String,
1253 entity_iri: &str,
1254 manchester: &str,
1255 predicate: &str,
1256 namespaces: &BTreeMap<String, String>,
1257) -> Result<()> {
1258 let ns = crate::span::namespaces_for_text(text, namespaces);
1259 let parsed = parse_class_expression(manchester, &ns)?;
1260 let triple = class_expression_to_turtle_fragment(&parsed.expression, predicate, &ns)?;
1261 insert_multiline_into_entity_block(text, entity_iri, &triple, namespaces)
1262}
1263
1264fn remove_complex_axiom(
1265 text: &mut String,
1266 entity_iri: &str,
1267 manchester: &str,
1268 predicate: &str,
1269 namespaces: &BTreeMap<String, String>,
1270) -> Result<()> {
1271 let ns = crate::span::namespaces_for_text(text, namespaces);
1272 let parsed = parse_class_expression(manchester, &ns)?;
1273 let object_value =
1274 crate::manchester::class_expression_to_turtle_value(&parsed.expression, &ns, 0)?;
1275 remove_predicate_object(text, entity_iri, predicate, &object_value, namespaces)
1276}
1277
1278fn insert_multiline_into_entity_block(
1279 text: &mut String,
1280 entity_iri: &str,
1281 insertion: &str,
1282 namespaces: &BTreeMap<String, String>,
1283) -> Result<()> {
1284 let range = entity_primary_range(text, entity_iri, namespaces)?;
1285 let block = &text[range.start as usize..range.end as usize];
1286 let trimmed = block.trim_end();
1287 let mut new_block = block.to_string();
1288 if trimmed.ends_with('.') {
1289 if let Some(pos) = new_block.trim_end().rfind('.') {
1290 let base = new_block[..pos].trim_end();
1291 new_block = format!("{base} ;\n{insertion}.");
1292 }
1293 } else if !trimmed.ends_with(';') {
1294 new_block.push_str(" ;\n");
1295 new_block.push_str(insertion);
1296 } else {
1297 new_block.push_str(insertion);
1298 }
1299 replace_range(text, range, &new_block);
1300 Ok(())
1301}
1302
1303fn add_subclass_triple(
1304 text: &mut String,
1305 entity_iri: &str,
1306 parent_iri: &str,
1307 namespaces: &BTreeMap<String, String>,
1308) -> Result<()> {
1309 let parent = iri_to_turtle_term(parent_iri, namespaces)?;
1310 add_object_triple(text, entity_iri, "rdfs:subClassOf", &parent, namespaces)
1311}
1312
1313fn predicate_to_term(predicate: &str, namespaces: &BTreeMap<String, String>) -> Result<String> {
1314 if let Some(curie) = known_curie_term(predicate, namespaces) {
1315 Ok(curie.to_string())
1316 } else {
1317 iri_to_turtle_term(predicate, namespaces)
1321 }
1322}
1323
1324fn known_curie_term<'a>(term: &'a str, namespaces: &BTreeMap<String, String>) -> Option<&'a str> {
1329 let (prefix, local) = term.split_once(':')?;
1330 if prefix.is_empty()
1331 || local.is_empty()
1332 || prefix.eq_ignore_ascii_case("http")
1333 || prefix.eq_ignore_ascii_case("https")
1334 || !namespaces.contains_key(prefix)
1335 || !prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1336 || !is_valid_pn_local(local)
1337 {
1338 return None;
1339 }
1340 Some(term)
1341}
1342
1343fn set_property_characteristic(
1344 text: &mut String,
1345 entity_iri: &str,
1346 characteristic: &str,
1347 value: bool,
1348 namespaces: &BTreeMap<String, String>,
1349) -> Result<()> {
1350 if value {
1351 add_type_triple(text, entity_iri, characteristic, namespaces)
1352 } else {
1353 remove_type_triple(text, entity_iri, characteristic, namespaces)
1354 }
1355}
1356
1357fn add_type_triple(
1358 text: &mut String,
1359 entity_iri: &str,
1360 type_term: &str,
1361 namespaces: &BTreeMap<String, String>,
1362) -> Result<()> {
1363 if !text_contains_entity(text, entity_iri, namespaces) {
1364 return Err(OwlError::EntityNotFound(entity_iri.to_string()));
1365 }
1366 let subject = iri_to_turtle_term(entity_iri, namespaces)?;
1367 let ns = crate::span::namespaces_for_text(text, namespaces);
1368 if let Some(range) = entity_primary_block_range(text, entity_iri, &ns) {
1369 let block = &text[range.start as usize..range.end as usize];
1370 if block_has_type_term(block, type_term) {
1371 return Ok(());
1372 }
1373 let insertion = format!(" a {type_term} ;\n");
1374 return insert_into_entity_block(text, entity_iri, &insertion, namespaces, false);
1375 }
1376 let triple = format!("\n{subject} a {type_term} .\n");
1378 if !text.ends_with('\n') {
1379 text.push('\n');
1380 }
1381 text.push_str(&triple);
1382 Ok(())
1383}
1384
1385fn block_has_type_term(block: &str, type_term: &str) -> bool {
1387 let norm = normalize_ws(type_term.trim());
1388 for predicate in ["a", "rdf:type"] {
1389 let mut search_from = 0;
1390 while let Some(pred_pos) = find_predicate_token(block, search_from, predicate) {
1391 for (obj_start, obj_end) in objects_in_predicate_value(block, pred_pos, predicate) {
1392 if normalize_ws(block[obj_start..obj_end].trim()) == norm {
1393 return true;
1394 }
1395 }
1396 search_from = pred_pos + 1;
1397 }
1398 }
1399 false
1400}
1401
1402fn remove_type_triple(
1403 text: &mut String,
1404 entity_iri: &str,
1405 type_term: &str,
1406 namespaces: &BTreeMap<String, String>,
1407) -> Result<()> {
1408 let ns = crate::span::namespaces_for_text(text, namespaces);
1409 if let Some((prefix, local)) = type_term.split_once(':') {
1410 if !prefix.is_empty() {
1411 if let Some(base) = ns.get(prefix) {
1412 let iri = format!("{base}{local}");
1413 return remove_rdf_type_iri(text, entity_iri, &iri, namespaces);
1414 }
1415 }
1416 }
1417 if let Some(iri) = type_term.strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
1418 return remove_rdf_type_iri(text, entity_iri, iri, namespaces);
1419 }
1420 let mut last_err = None;
1421 for pred in ["a", "rdf:type"] {
1422 match remove_predicate_object_any_statement(text, entity_iri, pred, type_term, namespaces) {
1423 Ok(()) => return Ok(()),
1424 Err(e @ OwlError::EntityNotFound(_)) => return Err(e),
1425 Err(e) => last_err = Some(e),
1426 }
1427 }
1428 Err(last_err.unwrap_or_else(|| {
1429 OwlError::ManchesterInvalid(format!("no matching type axiom for {type_term}"))
1430 }))
1431}
1432
1433fn remove_rdf_type_iri(
1434 text: &mut String,
1435 entity_iri: &str,
1436 type_iri: &str,
1437 namespaces: &BTreeMap<String, String>,
1438) -> Result<()> {
1439 let mut last_err = None;
1440 for pred in ["a", "rdf:type"] {
1441 match remove_predicate_iri_object(text, entity_iri, pred, type_iri, namespaces) {
1442 Ok(()) => return Ok(()),
1443 Err(e @ OwlError::EntityNotFound(_)) => return Err(e),
1444 Err(e) => last_err = Some(e),
1445 }
1446 }
1447 Err(last_err.unwrap_or_else(|| {
1448 OwlError::ManchesterInvalid(format!("no matching type axiom for {type_iri}"))
1449 }))
1450}
1451
1452fn add_property_assertion_triple(
1453 text: &mut String,
1454 entity_iri: &str,
1455 property_term: &str,
1456 target_term: &str,
1457 namespaces: &BTreeMap<String, String>,
1458) -> Result<()> {
1459 if !text_contains_entity(text, entity_iri, namespaces) {
1460 return Err(OwlError::EntityNotFound(entity_iri.to_string()));
1461 }
1462 let triple = format!(" {property_term} {target_term} ;\n");
1463 insert_into_entity_block(text, entity_iri, &triple, namespaces, false)
1464}
1465
1466fn add_data_property_assertion(
1467 text: &mut String,
1468 entity_iri: &str,
1469 property_term: &str,
1470 value: &str,
1471 namespaces: &BTreeMap<String, String>,
1472) -> Result<()> {
1473 if !text_contains_entity(text, entity_iri, namespaces) {
1474 return Err(OwlError::EntityNotFound(entity_iri.to_string()));
1475 }
1476 let escaped = escape_turtle_string(value);
1477 let triple = format!(" {property_term} \"{escaped}\" ;\n");
1478 insert_into_entity_block(text, entity_iri, &triple, namespaces, false)
1479}
1480
1481fn entity_declared_as(
1482 text: &str,
1483 entity_iri: &str,
1484 owl_type: &str,
1485 namespaces: &BTreeMap<String, String>,
1486) -> bool {
1487 if !text_contains_entity(text, entity_iri, namespaces) {
1488 return false;
1489 }
1490 let namespaces = crate::span::namespaces_for_text(text, namespaces);
1491 let short = short_name_from_iri(entity_iri);
1492 all_entity_statement_ranges(text, entity_iri, &short, &namespaces).into_iter().any(|range| {
1493 let block = &text[range.start as usize..range.end as usize];
1494 block_has_type_term(block, owl_type)
1495 || ({
1496 if let Some((prefix, local)) = owl_type.split_once(':') {
1498 if let Some(base) = namespaces.get(prefix) {
1499 let abs = format!("<{base}{local}>");
1500 block_has_type_term(block, &abs)
1501 } else {
1502 false
1503 }
1504 } else {
1505 false
1506 }
1507 })
1508 })
1509}
1510
1511fn validate_property_chain_members(
1512 text: &str,
1513 properties: &[String],
1514 namespaces: &BTreeMap<String, String>,
1515) -> Result<()> {
1516 const INVALID_TYPES: &[&str] =
1517 &["owl:Class", "owl:NamedIndividual", "owl:DatatypeProperty", "owl:AnnotationProperty"];
1518 for iri in properties {
1519 for owl_type in INVALID_TYPES {
1520 if entity_declared_as(text, iri, owl_type, namespaces) {
1521 return Err(OwlError::PatchInvalid(format!(
1522 "property chain member {iri} is declared as {owl_type}, expected owl:ObjectProperty"
1523 )));
1524 }
1525 }
1526 }
1527 Ok(())
1528}
1529
1530fn add_property_chain(
1531 text: &mut String,
1532 entity_iri: &str,
1533 properties: &[String],
1534 namespaces: &BTreeMap<String, String>,
1535) -> Result<()> {
1536 if properties.is_empty() {
1537 return Err(OwlError::PatchInvalid(
1538 "property chain must have at least one property".into(),
1539 ));
1540 }
1541 validate_property_chain_members(text, properties, namespaces)?;
1542 let terms: Vec<String> =
1543 properties.iter().map(|p| iri_to_turtle_term(p, namespaces)).collect::<Result<Vec<_>>>()?;
1544 let chain_obj = format!("( {} )", terms.join(" "));
1545 add_object_triple(text, entity_iri, "owl:propertyChainAxiom", &chain_obj, namespaces)
1546}
1547
1548fn remove_property_chain(
1549 text: &mut String,
1550 entity_iri: &str,
1551 properties: &[String],
1552 namespaces: &BTreeMap<String, String>,
1553) -> Result<()> {
1554 let terms: Vec<String> =
1555 properties.iter().map(|p| iri_to_turtle_term(p, namespaces)).collect::<Result<Vec<_>>>()?;
1556 let chain_obj = format!("( {} )", terms.join(" "));
1557 remove_predicate_object_any_statement(
1558 text,
1559 entity_iri,
1560 "owl:propertyChainAxiom",
1561 &chain_obj,
1562 namespaces,
1563 )
1564}
1565
1566fn add_iri_list_axiom(
1567 text: &mut String,
1568 subject_iri: &str,
1569 predicate: &str,
1570 members: &[String],
1571 namespaces: &BTreeMap<String, String>,
1572) -> Result<()> {
1573 if members.is_empty() {
1574 return Err(OwlError::PatchInvalid(format!(
1575 "{predicate} axiom must have at least one member"
1576 )));
1577 }
1578 let terms: Vec<String> =
1579 members.iter().map(|p| iri_to_turtle_term(p, namespaces)).collect::<Result<Vec<_>>>()?;
1580 let list_obj = format!("( {} )", terms.join(" "));
1581 add_object_triple(text, subject_iri, predicate, &list_obj, namespaces)
1582}
1583
1584fn remove_iri_list_axiom(
1585 text: &mut String,
1586 subject_iri: &str,
1587 predicate: &str,
1588 members: &[String],
1589 namespaces: &BTreeMap<String, String>,
1590) -> Result<()> {
1591 let ns = crate::span::namespaces_for_text(text, namespaces);
1592 let want: std::collections::BTreeSet<_> = members.iter().cloned().collect();
1593 let terms: Vec<String> =
1595 members.iter().map(|p| iri_to_turtle_term(p, &ns)).collect::<Result<Vec<_>>>()?;
1596 let list_obj = format!("( {} )", terms.join(" "));
1597 if remove_predicate_object_any_statement(text, subject_iri, predicate, &list_obj, &ns).is_ok() {
1598 return Ok(());
1599 }
1600 let short = short_name_from_iri(subject_iri);
1602 let ranges = all_entity_statement_ranges(text, subject_iri, &short, &ns);
1603 if ranges.is_empty() {
1604 return Err(OwlError::EntityNotFound(subject_iri.to_string()));
1605 }
1606 for range in ranges {
1607 let block = &text[range.start as usize..range.end as usize];
1608 let mut search_from = 0;
1609 while let Some(pred_pos) = find_predicate_token(block, search_from, predicate) {
1610 for (obj_start, obj_end) in objects_in_predicate_value(block, pred_pos, predicate) {
1611 let obj = block[obj_start..obj_end].trim();
1612 if let Some(got) = parse_turtle_iri_list(obj, &ns) {
1613 let got_set: std::collections::BTreeSet<_> = got.into_iter().collect();
1614 if got_set == want {
1615 let (remove_start, remove_end) =
1616 extend_removal_span(block, pred_pos, predicate, obj_start, obj_end);
1617 let mut new_block = String::new();
1618 new_block.push_str(&block[..remove_start]);
1619 new_block.push_str(&block[remove_end..]);
1620 let cleaned = cleanup_block_separators(&new_block);
1621 replace_range(text, range, &cleaned);
1622 return Ok(());
1623 }
1624 }
1625 }
1626 search_from = pred_pos + 1;
1627 }
1628 }
1629 Err(OwlError::ManchesterInvalid(format!("no matching {predicate} axiom")))
1630}
1631
1632fn parse_turtle_iri_list(
1633 object: &str,
1634 namespaces: &BTreeMap<String, String>,
1635) -> Option<Vec<String>> {
1636 let trimmed = object.trim();
1637 if !trimmed.starts_with('(') || !trimmed.ends_with(')') {
1638 return None;
1639 }
1640 let inner = &trimmed[1..trimmed.len() - 1];
1641 let mut members = Vec::new();
1642 for term in tokenize_turtle_terms(inner) {
1643 members.push(expand_turtle_term_to_iri(&term, namespaces)?);
1644 }
1645 Some(members)
1646}
1647
1648fn add_pairwise_property_axioms(
1649 text: &mut String,
1650 properties: &[String],
1651 predicate: &str,
1652 namespaces: &BTreeMap<String, String>,
1653) -> Result<()> {
1654 if properties.len() < 2 {
1655 return Err(OwlError::PatchInvalid(format!(
1656 "{predicate} requires at least two properties"
1657 )));
1658 }
1659 for i in 0..properties.len() {
1661 for j in (i + 1)..properties.len() {
1662 let other = iri_to_turtle_term(&properties[j], namespaces)?;
1663 add_object_triple(text, &properties[i], predicate, &other, namespaces)?;
1664 }
1665 }
1666 Ok(())
1667}
1668
1669fn remove_pairwise_property_axioms(
1670 text: &mut String,
1671 properties: &[String],
1672 predicate: &str,
1673 namespaces: &BTreeMap<String, String>,
1674) -> Result<()> {
1675 if properties.len() < 2 {
1676 return Err(OwlError::PatchInvalid(format!(
1677 "{predicate} requires at least two properties"
1678 )));
1679 }
1680 let mut changed = false;
1681 for i in 0..properties.len() {
1682 for j in (i + 1)..properties.len() {
1683 if remove_predicate_iri_object(
1684 text,
1685 &properties[i],
1686 predicate,
1687 &properties[j],
1688 namespaces,
1689 )
1690 .is_ok()
1691 {
1692 changed = true;
1693 }
1694 if remove_predicate_iri_object(
1695 text,
1696 &properties[j],
1697 predicate,
1698 &properties[i],
1699 namespaces,
1700 )
1701 .is_ok()
1702 {
1703 changed = true;
1704 }
1705 }
1706 }
1707 if changed {
1708 Ok(())
1709 } else {
1710 Err(OwlError::ManchesterInvalid(format!("no matching {predicate} axiom")))
1711 }
1712}
1713
1714fn add_pairwise_individual_axioms(
1715 text: &mut String,
1716 individuals: &[String],
1717 predicate: &str,
1718 namespaces: &BTreeMap<String, String>,
1719) -> Result<()> {
1720 if individuals.len() < 2 {
1721 return Err(OwlError::PatchInvalid(format!(
1722 "{predicate} requires at least two individuals"
1723 )));
1724 }
1725 for window in individuals.windows(2) {
1726 let other = iri_to_turtle_term(&window[1], namespaces)?;
1727 add_object_triple(text, &window[0], predicate, &other, namespaces)?;
1728 }
1729 Ok(())
1730}
1731
1732fn remove_pairwise_individual_axioms(
1733 text: &mut String,
1734 individuals: &[String],
1735 predicate: &str,
1736 namespaces: &BTreeMap<String, String>,
1737) -> Result<()> {
1738 if individuals.len() < 2 {
1739 return Err(OwlError::PatchInvalid(format!(
1740 "{predicate} requires at least two individuals"
1741 )));
1742 }
1743 for window in individuals.windows(2) {
1744 remove_predicate_iri_object(text, &window[0], predicate, &window[1], namespaces)?;
1745 }
1746 Ok(())
1747}
1748
1749fn add_different_individuals(
1751 text: &mut String,
1752 individuals: &[String],
1753 namespaces: &BTreeMap<String, String>,
1754) -> Result<()> {
1755 if individuals.len() < 2 {
1756 return Err(OwlError::PatchInvalid(
1757 "owl:AllDifferent requires at least two individuals".into(),
1758 ));
1759 }
1760 let ns = namespaces_for_text(text, namespaces);
1761 let terms: Vec<String> =
1762 individuals.iter().map(|i| iri_to_turtle_term(i, &ns)).collect::<Result<_>>()?;
1763 let list = format!("( {} )", terms.join(" "));
1764 let block = format!("[] a owl:AllDifferent ;\n owl:distinctMembers {list} .\n");
1765 if all_different_block_covers(text, individuals, &ns, true)? {
1766 return Ok(());
1767 }
1768 if normalize_ws(text).contains(&normalize_ws(&block)) {
1770 return Ok(());
1771 }
1772 append_standalone_block(text, &block);
1773 Ok(())
1774}
1775
1776fn remove_different_individuals(
1777 text: &mut String,
1778 individuals: &[String],
1779 namespaces: &BTreeMap<String, String>,
1780) -> Result<()> {
1781 if individuals.len() < 2 {
1782 return Err(OwlError::PatchInvalid(
1783 "remove_different_individuals requires at least two individuals".into(),
1784 ));
1785 }
1786 let ns = namespaces_for_text(text, namespaces);
1787 let mut changed = false;
1788 while remove_or_rewrite_one_all_different(text, individuals, &ns)? {
1790 changed = true;
1791 }
1792 for i in 0..individuals.len() {
1794 for j in (i + 1)..individuals.len() {
1795 if remove_predicate_iri_object(
1796 text,
1797 &individuals[i],
1798 "owl:differentFrom",
1799 &individuals[j],
1800 &ns,
1801 )
1802 .is_ok()
1803 {
1804 changed = true;
1805 }
1806 if remove_predicate_iri_object(
1807 text,
1808 &individuals[j],
1809 "owl:differentFrom",
1810 &individuals[i],
1811 &ns,
1812 )
1813 .is_ok()
1814 {
1815 changed = true;
1816 }
1817 }
1818 }
1819 if changed {
1820 return Ok(());
1821 }
1822 Err(OwlError::ManchesterInvalid(
1823 "no matching owl:AllDifferent / owl:differentFrom axiom".into(),
1824 ))
1825}
1826
1827fn all_different_block_covers(
1828 text: &str,
1829 individuals: &[String],
1830 namespaces: &BTreeMap<String, String>,
1831 exact: bool,
1832) -> Result<bool> {
1833 for (start, end) in all_different_statement_ranges(text) {
1834 let block = &text[start..end];
1835 let Some(members) = parse_all_different_members(block, namespaces)? else {
1836 continue;
1837 };
1838 let member_set: std::collections::BTreeSet<_> = members.iter().cloned().collect();
1839 let want: std::collections::BTreeSet<_> = individuals.iter().cloned().collect();
1840 if exact {
1841 if member_set == want {
1842 return Ok(true);
1843 }
1844 } else if want.iter().all(|i| member_set.contains(i)) {
1845 return Ok(true);
1846 }
1847 }
1848 Ok(false)
1849}
1850
1851fn remove_or_rewrite_one_all_different(
1852 text: &mut String,
1853 individuals: &[String],
1854 namespaces: &BTreeMap<String, String>,
1855) -> Result<bool> {
1856 let want: std::collections::BTreeSet<_> = individuals.iter().cloned().collect();
1857 let ranges = all_different_statement_ranges(text);
1858 for (start, end) in ranges {
1859 let block = text[start..end].to_string();
1860 let Some(members) = parse_all_different_members(&block, namespaces)? else {
1861 continue;
1862 };
1863 let member_set: std::collections::BTreeSet<_> = members.iter().cloned().collect();
1864 if !want.iter().all(|i| member_set.contains(i)) {
1865 continue;
1866 }
1867 let remaining: Vec<String> = members.into_iter().filter(|m| !want.contains(m)).collect();
1868 if remaining.len() < 2 {
1869 let mut remove_start = start;
1871 let mut remove_end = end;
1872 if remove_start > 0 && text.as_bytes()[remove_start - 1] == b'\n' {
1873 remove_start -= 1;
1874 }
1875 if remove_end < text.len() && text.as_bytes()[remove_end] == b'\n' {
1876 remove_end += 1;
1877 }
1878 text.replace_range(remove_start..remove_end, "");
1879 return Ok(true);
1880 }
1881 let terms: Vec<String> =
1883 remaining.iter().map(|i| iri_to_turtle_term(i, namespaces)).collect::<Result<_>>()?;
1884 let new_list = format!("( {} )", terms.join(" "));
1885 let rewritten = rewrite_all_different_members_list(&block, &new_list).ok_or_else(|| {
1886 OwlError::PatchInvalid("failed to rewrite AllDifferent members".into())
1887 })?;
1888 text.replace_range(start..end, &rewritten);
1889 return Ok(true);
1890 }
1891 Ok(false)
1892}
1893
1894fn all_different_statement_ranges(text: &str) -> Vec<(usize, usize)> {
1896 let bytes = text.as_bytes();
1897 let mut out = Vec::new();
1898 let mut i = 0;
1899 let mut state = TurtleScanState::default();
1900 while i < bytes.len() {
1901 if state.in_comment || state.in_string() || state.in_iri {
1902 i = advance_turtle_scan(bytes, i, &mut state);
1903 continue;
1904 }
1905 if bytes.get(i..i + 2) == Some(b"[]") {
1908 let before_ok = i == 0
1909 || bytes[i - 1].is_ascii_whitespace()
1910 || matches!(bytes[i - 1], b';' | b',' | b'.');
1911 if before_ok {
1912 if let Some(end) = statement_end_byte(text, i) {
1913 let stmt = &text[i..end];
1914 if stmt.contains("owl:AllDifferent") {
1915 out.push((i, end));
1916 }
1917 i = end;
1918 continue;
1919 }
1920 }
1921 }
1922 if bytes[i] == b'[' {
1924 if let Some(end) = bracket_end_index(text, i) {
1925 let block = &text[i..end];
1926 if block.contains("owl:AllDifferent") {
1927 let mut remove_end = end;
1928 let after = text[end..].trim_start();
1929 let trim_len = text[end..].len() - after.len();
1930 remove_end += trim_len;
1931 if after.starts_with('.') {
1932 remove_end += 1;
1933 }
1934 out.push((i, remove_end));
1935 }
1936 i = end;
1937 continue;
1938 }
1939 }
1940 i = advance_turtle_scan(bytes, i, &mut state);
1941 }
1942 out
1943}
1944
1945fn parse_all_different_members(
1946 block: &str,
1947 namespaces: &BTreeMap<String, String>,
1948) -> Result<Option<Vec<String>>> {
1949 let lower_pred = if block.contains("owl:distinctMembers") {
1950 "owl:distinctMembers"
1951 } else if block.contains("owl:members") {
1952 "owl:members"
1953 } else {
1954 return Ok(None);
1955 };
1956 let Some(pred_at) = block.find(lower_pred) else {
1957 return Ok(None);
1958 };
1959 let after = &block[pred_at + lower_pred.len()..];
1960 let Some(paren) = after.find('(') else {
1961 return Ok(None);
1962 };
1963 let list_start = pred_at + lower_pred.len() + paren;
1964 let Some(list_end) = matching_paren_end(block, list_start) else {
1965 return Ok(None);
1966 };
1967 let inner = &block[list_start + 1..list_end];
1968 let mut members = Vec::new();
1969 for term in tokenize_turtle_terms(inner) {
1970 let Some(iri) = expand_turtle_term_to_iri(&term, namespaces) else {
1971 return Err(OwlError::PatchInvalid(format!(
1972 "AllDifferent member cannot be expanded (undeclared prefix?): {term}"
1973 )));
1974 };
1975 members.push(iri);
1976 }
1977 Ok(Some(members))
1978}
1979
1980fn rewrite_all_different_members_list(block: &str, new_list: &str) -> Option<String> {
1981 let pred = if block.contains("owl:distinctMembers") {
1982 "owl:distinctMembers"
1983 } else if block.contains("owl:members") {
1984 "owl:members"
1985 } else {
1986 return None;
1987 };
1988 let pred_at = block.find(pred)?;
1989 let after = &block[pred_at + pred.len()..];
1990 let paren = after.find('(')?;
1991 let list_start = pred_at + pred.len() + paren;
1992 let list_end = matching_paren_end(block, list_start)?;
1993 let mut out = String::with_capacity(block.len());
1994 out.push_str(&block[..list_start]);
1995 out.push_str(new_list);
1996 out.push_str(&block[list_end + 1..]);
1997 Some(out)
1998}
1999
2000fn matching_paren_end(text: &str, open: usize) -> Option<usize> {
2001 let bytes = text.as_bytes();
2002 if bytes.get(open) != Some(&b'(') {
2003 return None;
2004 }
2005 let mut depth = 0i32;
2006 let mut state = TurtleScanState::default();
2007 let mut i = open;
2008 while i < bytes.len() {
2009 if state.in_comment || state.in_string() || state.in_iri {
2010 i = advance_turtle_scan(bytes, i, &mut state);
2011 continue;
2012 }
2013 match bytes[i] {
2014 b'(' => {
2015 depth += 1;
2016 i += 1;
2017 }
2018 b')' => {
2019 depth -= 1;
2020 if depth == 0 {
2021 return Some(i);
2022 }
2023 i += 1;
2024 }
2025 b'<' | b'"' | b'\'' | b'#' => {
2026 i = advance_turtle_scan(bytes, i, &mut state);
2027 }
2028 _ => i += 1,
2029 }
2030 }
2031 None
2032}
2033
2034fn tokenize_turtle_terms(inner: &str) -> Vec<String> {
2035 let bytes = inner.as_bytes();
2036 let mut terms = Vec::new();
2037 let mut i = 0;
2038 let mut state = TurtleScanState::default();
2039 while i < bytes.len() {
2040 if bytes[i].is_ascii_whitespace() {
2041 i += 1;
2042 continue;
2043 }
2044 if state.in_comment || state.in_string() || state.in_iri {
2045 i = advance_turtle_scan(bytes, i, &mut state);
2046 continue;
2047 }
2048 if bytes[i] == b'<' {
2049 let start = i;
2050 state.in_iri = true;
2051 i += 1;
2052 while i < bytes.len() && state.in_iri {
2053 i = advance_turtle_scan(bytes, i, &mut state);
2054 }
2055 terms.push(inner[start..i].to_string());
2056 continue;
2057 }
2058 let start = i;
2060 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
2061 i += 1;
2062 }
2063 terms.push(inner[start..i].to_string());
2064 }
2065 terms
2066}
2067
2068fn expand_turtle_term_to_iri(term: &str, namespaces: &BTreeMap<String, String>) -> Option<String> {
2069 let t = term.trim();
2070 if t.starts_with('<') && t.ends_with('>') && t.len() >= 2 {
2071 return Some(t[1..t.len() - 1].to_string());
2072 }
2073 if let Some((prefix, local)) = t.split_once(':') {
2074 if let Some(base) = namespaces.get(prefix) {
2075 return Some(format!("{base}{local}"));
2076 }
2077 if prefix.is_empty() {
2079 if let Some(base) = namespaces.get("") {
2080 return Some(format!("{base}{local}"));
2081 }
2082 }
2083 }
2084 None
2085}
2086
2087fn append_standalone_block(text: &mut String, block: &str) {
2088 if !text.is_empty() && !text.ends_with('\n') {
2089 text.push('\n');
2090 }
2091 if !text.is_empty() && !text.ends_with("\n\n") {
2092 text.push('\n');
2093 }
2094 text.push_str(block);
2095 if !block.ends_with('\n') {
2096 text.push('\n');
2097 }
2098}
2099
2100fn add_negative_object_property_assertion(
2101 text: &mut String,
2102 entity_iri: &str,
2103 property_iri: &str,
2104 target_iri: &str,
2105 namespaces: &BTreeMap<String, String>,
2106) -> Result<()> {
2107 let source = iri_to_turtle_term(entity_iri, namespaces)?;
2108 let prop = iri_to_turtle_term(property_iri, namespaces)?;
2109 let target = iri_to_turtle_term(target_iri, namespaces)?;
2110 let block = format!(
2111 "[ rdf:type owl:NegativePropertyAssertion ;\n owl:sourceIndividual {source} ;\n owl:assertionProperty {prop} ;\n owl:targetIndividual {target}\n] .\n"
2112 );
2113 if normalize_ws(text).contains(&normalize_ws(&block)) {
2114 return Ok(());
2115 }
2116 append_standalone_block(text, &block);
2117 Ok(())
2118}
2119
2120fn remove_negative_object_property_assertion(
2121 text: &mut String,
2122 entity_iri: &str,
2123 property_iri: &str,
2124 target_iri: &str,
2125 namespaces: &BTreeMap<String, String>,
2126) -> Result<()> {
2127 let source = iri_to_turtle_term(entity_iri, namespaces)?;
2128 let prop = iri_to_turtle_term(property_iri, namespaces)?;
2129 let target = iri_to_turtle_term(target_iri, namespaces)?;
2130 remove_matching_npa_block(text, &source, &prop, Some(&target), None)
2131}
2132
2133fn add_negative_data_property_assertion(
2134 text: &mut String,
2135 entity_iri: &str,
2136 property_iri: &str,
2137 value: &str,
2138 namespaces: &BTreeMap<String, String>,
2139) -> Result<()> {
2140 let source = iri_to_turtle_term(entity_iri, namespaces)?;
2141 let prop = iri_to_turtle_term(property_iri, namespaces)?;
2142 let escaped = escape_turtle_string(value);
2143 let block = format!(
2144 "[ rdf:type owl:NegativePropertyAssertion ;\n owl:sourceIndividual {source} ;\n owl:assertionProperty {prop} ;\n owl:targetValue \"{escaped}\"\n] .\n"
2145 );
2146 if normalize_ws(text).contains(&normalize_ws(&block)) {
2147 return Ok(());
2148 }
2149 append_standalone_block(text, &block);
2150 Ok(())
2151}
2152
2153fn remove_negative_data_property_assertion(
2154 text: &mut String,
2155 entity_iri: &str,
2156 property_iri: &str,
2157 value: &str,
2158 namespaces: &BTreeMap<String, String>,
2159) -> Result<()> {
2160 let source = iri_to_turtle_term(entity_iri, namespaces)?;
2161 let prop = iri_to_turtle_term(property_iri, namespaces)?;
2162 remove_matching_npa_block(text, &source, &prop, None, Some(value))
2163}
2164
2165fn remove_matching_npa_block(
2166 text: &mut String,
2167 source: &str,
2168 prop: &str,
2169 target_individual: Option<&str>,
2170 target_value: Option<&str>,
2171) -> Result<()> {
2172 let bytes = text.as_bytes();
2173 let mut i = 0;
2174 let mut state = TurtleScanState::default();
2175 while i < bytes.len() {
2176 if state.in_comment || state.in_string() || state.in_iri {
2177 i = advance_turtle_scan(bytes, i, &mut state);
2178 continue;
2179 }
2180 if bytes[i] == b'[' {
2181 if let Some(end) = bracket_end_index(text, i) {
2182 let block = &text[i..end];
2183 if block.contains("owl:NegativePropertyAssertion")
2184 && block.contains(source)
2185 && block.contains(prop)
2186 && target_individual.map(|t| block.contains(t)).unwrap_or(true)
2187 && target_value
2188 .map(|v| {
2189 let lexical = v.trim().trim_matches('"').trim_matches('\'');
2190 block.contains(lexical)
2191 })
2192 .unwrap_or(true)
2193 {
2194 let mut remove_end = end;
2195 let after = text[end..].trim_start();
2196 let trim_len = text[end..].len() - after.len();
2197 remove_end += trim_len;
2198 if after.starts_with('.') {
2199 remove_end += 1;
2200 let rest = &text[remove_end..];
2201 if rest.starts_with('\n') {
2202 remove_end += 1;
2203 }
2204 }
2205 let mut remove_start = i;
2207 if remove_start > 0 && text.as_bytes()[remove_start - 1] == b'\n' {
2208 remove_start -= 1;
2209 }
2210 text.replace_range(remove_start..remove_end, "");
2211 return Ok(());
2212 }
2213 i = end;
2214 continue;
2215 }
2216 }
2217 i = advance_turtle_scan(bytes, i, &mut state);
2218 }
2219 Err(OwlError::ManchesterInvalid("no matching NegativePropertyAssertion axiom".to_string()))
2220}
2221
2222fn add_datatype_definition(
2223 text: &mut String,
2224 datatype_iri: &str,
2225 manchester: &str,
2226 namespaces: &BTreeMap<String, String>,
2227) -> Result<()> {
2228 if !text_contains_entity(text, datatype_iri, namespaces) {
2229 create_entity(text, datatype_iri, PatchEntityKind::Datatype, namespaces)?;
2230 } else if !entity_declared_as(text, datatype_iri, "rdfs:Datatype", namespaces) {
2231 add_type_triple(text, datatype_iri, "rdfs:Datatype", namespaces)?;
2232 }
2233 let ns = crate::span::namespaces_for_text(text, namespaces);
2234 let trimmed = manchester.trim();
2235 let object = crate::manchester::parse_data_range(trimmed, &ns)
2236 .and_then(|dr| crate::manchester::data_range_to_turtle_term(&dr, &ns))
2237 .or_else(|_| iri_to_turtle_term(trimmed, namespaces))?;
2238 add_object_triple(text, datatype_iri, "owl:equivalentClass", &object, namespaces)
2239}
2240
2241fn remove_datatype_definition(
2242 text: &mut String,
2243 datatype_iri: &str,
2244 manchester: &str,
2245 namespaces: &BTreeMap<String, String>,
2246) -> Result<()> {
2247 let ns = crate::span::namespaces_for_text(text, namespaces);
2248 let trimmed = manchester.trim();
2249 let object = crate::manchester::parse_data_range(trimmed, &ns)
2250 .and_then(|dr| crate::manchester::data_range_to_turtle_term(&dr, &ns))
2251 .or_else(|_| iri_to_turtle_term(trimmed, namespaces))?;
2252 remove_predicate_object_any_statement(
2253 text,
2254 datatype_iri,
2255 "owl:equivalentClass",
2256 &object,
2257 namespaces,
2258 )
2259}
2260
2261fn axiom_op_predicate(axiom_op: &str) -> Result<&'static str> {
2262 Ok(match axiom_op {
2263 "sub_class_of" => "rdfs:subClassOf",
2264 "disjoint_with" | "disjoint_class" => "owl:disjointWith",
2265 "equivalent_class" => "owl:equivalentClass",
2266 "domain" => "rdfs:domain",
2267 "range" => "rdfs:range",
2268 "sub_object_property_of" | "sub_data_property_of" | "sub_property_of" => {
2269 "rdfs:subPropertyOf"
2270 }
2271 "inverse_of" | "inverse_object_properties" => "owl:inverseOf",
2272 "equivalent_property" | "equivalent_object_properties" | "equivalent_data_properties" => {
2273 "owl:equivalentProperty"
2274 }
2275 "property_disjoint_with" | "disjoint_object_properties" | "disjoint_data_properties" => {
2276 "owl:propertyDisjointWith"
2277 }
2278 "same_as" | "same_individual" => "owl:sameAs",
2279 "different_from" | "different_individuals" => "owl:differentFrom",
2280 other => {
2281 return Err(OwlError::PatchInvalid(format!(
2282 "unsupported axiom_op for axiom annotation: {other}"
2283 )));
2284 }
2285 })
2286}
2287
2288fn add_axiom_annotation(
2289 text: &mut String,
2290 axiom_op: &str,
2291 subject_iri: &str,
2292 related_iri: Option<&str>,
2293 predicate: &str,
2294 value: &str,
2295 namespaces: &BTreeMap<String, String>,
2296) -> Result<()> {
2297 let annotated_property = axiom_op_predicate(axiom_op)?;
2298 let source = iri_to_turtle_term(subject_iri, namespaces)?;
2299 let target = match related_iri {
2300 Some(iri) => iri_to_turtle_term(iri, namespaces)?,
2301 None => {
2302 return Err(OwlError::PatchInvalid(
2303 "axiom annotation requires related_iri for the annotated target".into(),
2304 ));
2305 }
2306 };
2307 let ann_pred = predicate_to_term(predicate, namespaces)?;
2308 let ann_value = if let Some(obj) = explicit_iri_annotation_term(value, namespaces)? {
2309 obj
2310 } else {
2311 format!("\"{}\"", escape_turtle_string(value))
2312 };
2313 let block = format!(
2314 "[ rdf:type owl:Axiom ;\n owl:annotatedSource {source} ;\n owl:annotatedProperty {annotated_property} ;\n owl:annotatedTarget {target} ;\n {ann_pred} {ann_value}\n] .\n"
2315 );
2316 if normalize_ws(text).contains(&normalize_ws(&block)) {
2317 return Ok(());
2318 }
2319 append_standalone_block(text, &block);
2320 Ok(())
2321}
2322
2323fn remove_axiom_annotation(
2324 text: &mut String,
2325 axiom_op: &str,
2326 subject_iri: &str,
2327 related_iri: Option<&str>,
2328 predicate: &str,
2329 value: &str,
2330 namespaces: &BTreeMap<String, String>,
2331) -> Result<()> {
2332 let annotated_property = axiom_op_predicate(axiom_op)?;
2333 let source = iri_to_turtle_term(subject_iri, namespaces)?;
2334 let target = match related_iri {
2335 Some(iri) => iri_to_turtle_term(iri, namespaces)?,
2336 None => {
2337 return Err(OwlError::PatchInvalid(
2338 "axiom annotation requires related_iri for the annotated target".into(),
2339 ));
2340 }
2341 };
2342 let ann_pred = predicate_to_term(predicate, namespaces)?;
2343 let lexical = value.trim().trim_matches('"').trim_matches('\'');
2344 let bytes = text.as_bytes();
2345 let mut i = 0;
2346 let mut state = TurtleScanState::default();
2347 while i < bytes.len() {
2348 if state.in_comment || state.in_string() || state.in_iri {
2349 i = advance_turtle_scan(bytes, i, &mut state);
2350 continue;
2351 }
2352 if bytes[i] == b'[' {
2353 if let Some(end) = bracket_end_index(text, i) {
2354 let block = &text[i..end];
2355 if block.contains("owl:Axiom")
2356 && block.contains(&source)
2357 && block.contains(annotated_property)
2358 && block.contains(&target)
2359 && block.contains(&ann_pred)
2360 && axiom_block_has_exact_annotation_value(block, &ann_pred, lexical)
2361 {
2362 let mut remove_end = end;
2363 let after = text[end..].trim_start();
2364 let trim_len = text[end..].len() - after.len();
2365 remove_end += trim_len;
2366 if after.starts_with('.') {
2367 remove_end += 1;
2368 if text[remove_end..].starts_with('\n') {
2369 remove_end += 1;
2370 }
2371 }
2372 let mut remove_start = i;
2373 if remove_start > 0 && text.as_bytes()[remove_start - 1] == b'\n' {
2374 remove_start -= 1;
2375 }
2376 text.replace_range(remove_start..remove_end, "");
2377 return Ok(());
2378 }
2379 i = end;
2380 continue;
2381 }
2382 }
2383 i = advance_turtle_scan(bytes, i, &mut state);
2384 }
2385 Err(OwlError::ManchesterInvalid("no matching axiom annotation".to_string()))
2386}
2387
2388fn axiom_block_has_exact_annotation_value(block: &str, ann_pred: &str, lexical: &str) -> bool {
2389 let inner =
2391 block.strip_prefix('[').and_then(|s| s.trim_end().strip_suffix(']')).unwrap_or(block);
2392 let norm_value = normalize_ws(lexical);
2393 let mut search_from = 0;
2394 while let Some(pred_pos) = find_predicate_token(inner, search_from, ann_pred) {
2395 for (obj_start, obj_end) in objects_in_predicate_value(inner, pred_pos, ann_pred) {
2396 let obj_text = inner[obj_start..obj_end].trim();
2397 if let Some(got) = turtle_literal_lexical_value(obj_text) {
2398 if normalize_ws(&got) == norm_value {
2399 return true;
2400 }
2401 } else {
2402 let bare = obj_text.trim().trim_matches(|c| c == '<' || c == '>');
2403 if normalize_ws(bare) == norm_value {
2404 return true;
2405 }
2406 }
2407 }
2408 search_from = pred_pos + 1;
2409 }
2410 false
2411}
2412
2413fn remove_predicate_triples(
2414 text: &mut String,
2415 entity_iri: &str,
2416 predicate: &str,
2417 namespaces: &BTreeMap<String, String>,
2418) -> Result<()> {
2419 remove_all_predicate_any_statement(text, entity_iri, predicate, namespaces)
2420}
2421
2422fn remove_predicate_object(
2423 text: &mut String,
2424 entity_iri: &str,
2425 predicate: &str,
2426 object_value: &str,
2427 namespaces: &BTreeMap<String, String>,
2428) -> Result<()> {
2429 remove_predicate_object_any_statement(text, entity_iri, predicate, object_value, namespaces)
2430}
2431
2432fn remove_matching_predicate_any(
2433 text: &mut String,
2434 entity_iri: &str,
2435 predicate: &str,
2436 value: &str,
2437 namespaces: &BTreeMap<String, String>,
2438) -> Result<()> {
2439 let value = value.trim().trim_matches('"').trim_matches('\'');
2440 let ns = crate::span::namespaces_for_text(text, namespaces);
2441 let short = short_name_from_iri(entity_iri);
2442 let ranges = all_entity_statement_ranges(text, entity_iri, &short, &ns);
2443 if ranges.is_empty() {
2444 return Err(OwlError::EntityNotFound(entity_iri.to_string()));
2445 }
2446 for range in ranges {
2447 let block = &text[range.start as usize..range.end as usize];
2448 if let Some(new_block) = remove_matching_predicate_by_lexical_value(block, predicate, value)
2449 {
2450 replace_range(text, range, &new_block);
2451 return Ok(());
2452 }
2453 }
2454 Err(OwlError::ManchesterInvalid(format!("no matching {predicate} axiom")))
2455}
2456
2457fn remove_all_predicate_any_statement(
2458 text: &mut String,
2459 entity_iri: &str,
2460 predicate: &str,
2461 namespaces: &BTreeMap<String, String>,
2462) -> Result<()> {
2463 loop {
2464 let ns = crate::span::namespaces_for_text(text, namespaces);
2465 let short = short_name_from_iri(entity_iri);
2466 let ranges = all_entity_statement_ranges(text, entity_iri, &short, &ns);
2467 if ranges.is_empty() {
2468 return Ok(());
2469 }
2470 let mut removed = false;
2471 for range in ranges {
2472 let block = &text[range.start as usize..range.end as usize];
2473 let new_block = remove_all_predicate_objects(block, predicate);
2474 if new_block != block {
2475 replace_range(text, range, &new_block);
2476 removed = true;
2477 break;
2478 }
2479 }
2480 if !removed {
2481 return Ok(());
2482 }
2483 }
2484}
2485
2486fn insert_into_entity_block(
2487 text: &mut String,
2488 entity_iri: &str,
2489 insertion: &str,
2490 namespaces: &BTreeMap<String, String>,
2491 duplicate_is_error: bool,
2492) -> Result<()> {
2493 if let Some((predicate, object)) = parse_simple_insertion(insertion) {
2494 if entity_has_predicate_object(text, entity_iri, &predicate, &object, namespaces) {
2495 if duplicate_is_error {
2496 return Err(OwlError::PatchInvalid(format!(
2497 "duplicate {predicate} axiom already present: {object}"
2498 )));
2499 }
2500 return Ok(());
2501 }
2502 }
2503 insert_multiline_into_entity_block(text, entity_iri, insertion, namespaces)
2505}
2506
2507fn parse_simple_insertion(insertion: &str) -> Option<(String, String)> {
2508 let line = insertion.lines().next()?.trim().trim_end_matches(';').trim();
2509 let mut parts = line.splitn(2, char::is_whitespace);
2510 let predicate = parts.next()?.to_string();
2511 let object = parts.next()?.trim().to_string();
2512 if predicate.is_empty() || object.is_empty() {
2513 return None;
2514 }
2515 Some((predicate, object))
2516}
2517
2518fn entity_has_predicate_object(
2519 text: &str,
2520 entity_iri: &str,
2521 predicate: &str,
2522 object_value: &str,
2523 namespaces: &BTreeMap<String, String>,
2524) -> bool {
2525 let ns = crate::span::namespaces_for_text(text, namespaces);
2526 let short = short_name_from_iri(entity_iri);
2527 all_entity_statement_ranges(text, entity_iri, &short, &ns).into_iter().any(|range| {
2528 let block = &text[range.start as usize..range.end as usize];
2529 block_has_matching_predicate_object(block, predicate, object_value)
2530 })
2531}
2532
2533fn block_has_matching_predicate_object(block: &str, predicate: &str, object_value: &str) -> bool {
2534 remove_matching_predicate_object(block, predicate, object_value).is_some()
2535}
2536
2537fn replace_range(text: &mut String, range: ByteRange, replacement: &str) {
2538 let start = range.start as usize;
2539 let end = range.end.min(text.len() as u64) as usize;
2540 text.replace_range(start..end, replacement);
2541}
2542
2543fn escape_turtle_string(value: &str) -> String {
2544 let mut out = String::with_capacity(value.len());
2545 for ch in value.chars() {
2546 match ch {
2547 '\\' => out.push_str("\\\\"),
2548 '"' => out.push_str("\\\""),
2549 '\n' => out.push_str("\\n"),
2550 '\r' => out.push_str("\\r"),
2551 '\t' => out.push_str("\\t"),
2552 c => out.push(c),
2553 }
2554 }
2555 out
2556}
2557
2558fn entity_primary_range(
2559 text: &str,
2560 entity_iri: &str,
2561 namespaces: &BTreeMap<String, String>,
2562) -> Result<ByteRange> {
2563 let ns = crate::span::namespaces_for_text(text, namespaces);
2564 entity_primary_block_range(text, entity_iri, &ns)
2565 .ok_or_else(|| OwlError::EntityNotFound(entity_iri.to_string()))
2566}
2567
2568fn remove_predicate_object_any_statement(
2569 text: &mut String,
2570 entity_iri: &str,
2571 predicate: &str,
2572 object_value: &str,
2573 namespaces: &BTreeMap<String, String>,
2574) -> Result<()> {
2575 let ns = crate::span::namespaces_for_text(text, namespaces);
2576 let short = short_name_from_iri(entity_iri);
2577 let ranges = all_entity_statement_ranges(text, entity_iri, &short, &ns);
2578 if ranges.is_empty() {
2579 return Err(OwlError::EntityNotFound(entity_iri.to_string()));
2580 }
2581 for range in ranges {
2582 let block = &text[range.start as usize..range.end as usize];
2583 if let Some(new_block) = remove_matching_predicate_object(block, predicate, object_value) {
2584 replace_range(text, range, &new_block);
2585 return Ok(());
2586 }
2587 }
2588 Err(OwlError::ManchesterInvalid(format!("no matching {predicate} axiom")))
2589}
2590
2591fn remove_predicate_iri_object(
2593 text: &mut String,
2594 entity_iri: &str,
2595 predicate: &str,
2596 object_iri: &str,
2597 namespaces: &BTreeMap<String, String>,
2598) -> Result<()> {
2599 let ns = crate::span::namespaces_for_text(text, namespaces);
2600 let forms = iri_turtle_term_forms(object_iri, &ns)?;
2601 let short = short_name_from_iri(entity_iri);
2602 let ranges = all_entity_statement_ranges(text, entity_iri, &short, &ns);
2603 if ranges.is_empty() {
2604 return Err(OwlError::EntityNotFound(entity_iri.to_string()));
2605 }
2606 for range in ranges {
2607 let block = &text[range.start as usize..range.end as usize];
2608 for form in &forms {
2609 if let Some(new_block) = remove_matching_predicate_object(block, predicate, form) {
2610 replace_range(text, range, &new_block);
2611 return Ok(());
2612 }
2613 }
2614 }
2615 Err(OwlError::ManchesterInvalid(format!("no matching {predicate} axiom")))
2616}
2617
2618fn iri_turtle_term_forms(iri: &str, namespaces: &BTreeMap<String, String>) -> Result<Vec<String>> {
2620 if !is_safe_iri(iri) {
2621 return Err(OwlError::PatchInvalid(format!(
2622 "IRI contains characters that cannot be safely written to Turtle: {iri:?}"
2623 )));
2624 }
2625 let mut forms = vec![format!("<{iri}>")];
2626 if let Some((prefix, ns)) = best_namespace_match(iri, namespaces) {
2627 let local = &iri[ns.len()..];
2628 if is_valid_pn_local(local) {
2629 let curie = format!("{prefix}:{local}");
2630 if !forms.iter().any(|f| f == &curie) {
2631 forms.push(curie);
2632 }
2633 }
2634 }
2635 if let Some(default_ns) = namespaces.get("") {
2636 if iri.starts_with(default_ns.as_str()) {
2637 let local = &iri[default_ns.len()..];
2638 if is_valid_pn_local(local) {
2639 let term = format!(":{local}");
2640 if !forms.iter().any(|f| f == &term) {
2641 forms.push(term);
2642 }
2643 }
2644 }
2645 }
2646 if iri == "http://www.w3.org/2002/07/owl#Thing" {
2647 let thing = "owl:Thing".to_string();
2648 if !forms.iter().any(|f| f == &thing) {
2649 forms.push(thing);
2650 }
2651 }
2652 Ok(forms)
2653}
2654
2655fn normalize_ws(s: &str) -> String {
2656 s.split_whitespace().collect::<Vec<_>>().join(" ")
2657}
2658
2659fn bracket_end_index(text: &str, bracket_start: usize) -> Option<usize> {
2660 let bytes = text.as_bytes();
2661 if bytes.get(bracket_start) != Some(&b'[') {
2662 return None;
2663 }
2664 let mut depth = 0i32;
2665 let mut state = TurtleScanState::default();
2666 let mut i = bracket_start;
2667 while i < bytes.len() {
2668 if state.in_comment || state.in_string() || state.in_iri {
2669 i = advance_turtle_scan(bytes, i, &mut state);
2670 continue;
2671 }
2672 if is_turtle_lex_start(bytes, i) {
2673 i = advance_turtle_scan(bytes, i, &mut state);
2674 continue;
2675 }
2676 match bytes[i] {
2677 b'[' => {
2678 depth += 1;
2679 i += 1;
2680 }
2681 b']' => {
2682 depth -= 1;
2683 if depth == 0 {
2684 return Some(i + 1);
2685 }
2686 i += 1;
2687 }
2688 _ => i += 1,
2689 }
2690 }
2691 None
2692}
2693
2694fn is_turtle_lex_start(bytes: &[u8], i: usize) -> bool {
2695 matches!(bytes.get(i), Some(b'#' | b'"' | b'\'' | b'<'))
2696 || bytes.get(i..i + 3) == Some(br#"""""#)
2697 || bytes.get(i..i + 3) == Some(br"'''")
2698}
2699
2700fn extend_removal_span(
2702 block: &str,
2703 pred_pos: usize,
2704 predicate: &str,
2705 obj_start: usize,
2706 obj_end: usize,
2707) -> (usize, usize) {
2708 let objects = objects_in_predicate_value(block, pred_pos, predicate);
2709 let is_only_object = objects.len() == 1;
2710
2711 let mut start = if is_only_object { pred_pos } else { obj_start };
2712 while start > 0 && block.as_bytes()[start - 1].is_ascii_whitespace() {
2713 start -= 1;
2714 }
2715 if !is_only_object && start > 0 && block.as_bytes()[start - 1] == b',' {
2716 start -= 1;
2717 while start > 0 && block.as_bytes()[start - 1].is_ascii_whitespace() {
2718 start -= 1;
2719 }
2720 }
2721
2722 let mut end = obj_end;
2723 while end < block.len() && block.as_bytes()[end].is_ascii_whitespace() {
2724 end += 1;
2725 }
2726 if end < block.len() && (block.as_bytes()[end] == b',' || block.as_bytes()[end] == b';') {
2727 end += 1;
2728 }
2729 (start, end)
2730}
2731
2732fn cleanup_block_separators(block: &str) -> String {
2733 let mut lines: Vec<&str> = block.lines().collect();
2734 while lines.last().is_some_and(|l| l.trim().is_empty()) {
2735 lines.pop();
2736 }
2737 lines.join("\n").replace(";\n ;", ";\n").replace(",\n ,", ",\n")
2738}
2739
2740fn remove_all_predicate_objects(block: &str, predicate: &str) -> String {
2741 let mut result = block.to_string();
2742 while let Some(next) = remove_first_predicate_object(&result, predicate) {
2743 result = next;
2744 }
2745 cleanup_block_separators(&result)
2746}
2747
2748fn remove_first_predicate_object(block: &str, predicate: &str) -> Option<String> {
2749 let pred_pos = find_predicate_token(block, 0, predicate)?;
2750 let (obj_start, obj_end) =
2751 objects_in_predicate_value(block, pred_pos, predicate).first().copied()?;
2752 let (remove_start, remove_end) =
2753 extend_removal_span(block, pred_pos, predicate, obj_start, obj_end);
2754 let mut out = String::new();
2755 out.push_str(&block[..remove_start]);
2756 out.push_str(&block[remove_end..]);
2757 Some(cleanup_block_separators(&out))
2758}
2759
2760fn find_predicate_token(block: &str, search_from: usize, predicate: &str) -> Option<usize> {
2762 let bytes = block.as_bytes();
2763 let pred_bytes = predicate.as_bytes();
2764 if pred_bytes.is_empty() || search_from >= bytes.len() {
2765 return None;
2766 }
2767 let mut i = search_from;
2768 let mut state = TurtleScanState::default();
2769 let mut bracket_depth = 0i32;
2770 while i + pred_bytes.len() <= bytes.len() {
2771 if state.in_comment || state.in_string() || state.in_iri {
2772 i = advance_turtle_scan(bytes, i, &mut state);
2773 continue;
2774 }
2775 if is_turtle_lex_start(bytes, i) {
2776 i = advance_turtle_scan(bytes, i, &mut state);
2777 continue;
2778 }
2779 match bytes[i] {
2780 b'[' => {
2781 bracket_depth += 1;
2782 i += 1;
2783 continue;
2784 }
2785 b']' => {
2786 bracket_depth = bracket_depth.saturating_sub(1);
2787 i += 1;
2788 continue;
2789 }
2790 _ => {}
2791 }
2792 if bracket_depth == 0 && bytes[i..].starts_with(pred_bytes) {
2793 let after = i + pred_bytes.len();
2794 let before_ok = i == 0
2795 || !bytes[i - 1].is_ascii_alphanumeric()
2796 && bytes[i - 1] != b':'
2797 && bytes[i - 1] != b'_';
2798 let after_ok = after >= bytes.len()
2799 || bytes[after].is_ascii_whitespace()
2800 || bytes[after] == b';'
2801 || bytes[after] == b'.'
2802 || bytes[after] == b',';
2803 if before_ok && after_ok {
2804 return Some(i);
2805 }
2806 }
2807 i += 1;
2808 }
2809 None
2810}
2811
2812fn find_named_object_end(block: &str, obj_start: usize) -> Option<usize> {
2813 use crate::span::is_turtle_terminating_dot;
2814 let bytes = block.as_bytes();
2815 let mut i = obj_start;
2816 let mut state = TurtleScanState::default();
2817 while i < bytes.len() {
2818 if state.in_comment || state.in_string() || state.in_iri {
2819 i = advance_turtle_scan(bytes, i, &mut state);
2820 continue;
2821 }
2822 if is_turtle_lex_start(bytes, i) {
2823 i = advance_turtle_scan(bytes, i, &mut state);
2824 continue;
2825 }
2826 match bytes[i] {
2827 b',' | b';' => return Some(i),
2828 b'.' if is_turtle_terminating_dot(bytes, i) => return Some(i),
2829 _ => i += 1,
2830 }
2831 }
2832 Some(block.len())
2833}
2834
2835fn objects_in_predicate_value(
2836 block: &str,
2837 pred_pos: usize,
2838 predicate: &str,
2839) -> Vec<(usize, usize)> {
2840 let list_start = pred_pos + predicate.len();
2841 let mut objects = Vec::new();
2842 let mut i = list_start;
2843 loop {
2844 let rest = block.get(i..).unwrap_or("").trim_start();
2845 if rest.is_empty() || rest.starts_with(';') || rest.starts_with('.') {
2846 break;
2847 }
2848 i += block[i..].len() - rest.len();
2849 if block.as_bytes().get(i) == Some(&b'[') {
2850 if let Some(end) = bracket_end_index(block, i) {
2851 objects.push((i, end));
2852 i = end;
2853 } else {
2854 break;
2855 }
2856 } else {
2857 let end = find_named_object_end(block, i).unwrap_or(block.len());
2858 objects.push((i, end));
2859 i = end;
2860 }
2861 let rest = block.get(i..).unwrap_or("").trim_start();
2862 i += block[i..].len() - rest.len();
2863 if rest.starts_with(',') {
2864 i += 1;
2865 } else {
2866 break;
2867 }
2868 }
2869 objects
2870}
2871
2872fn remove_matching_predicate_object(
2873 block: &str,
2874 predicate: &str,
2875 object_value: &str,
2876) -> Option<String> {
2877 let obj_trim = object_value.trim();
2878 let norm_obj = normalize_ws(obj_trim);
2879 let mut search_from = 0;
2880 while let Some(pred_pos) = find_predicate_token(block, search_from, predicate) {
2881 for (obj_start, obj_end) in objects_in_predicate_value(block, pred_pos, predicate) {
2882 let candidate = normalize_ws(block[obj_start..obj_end].trim());
2883 if candidate == norm_obj {
2884 let (remove_start, remove_end) =
2885 extend_removal_span(block, pred_pos, predicate, obj_start, obj_end);
2886 let mut out = String::new();
2887 out.push_str(&block[..remove_start]);
2888 out.push_str(&block[remove_end..]);
2889 return Some(cleanup_block_separators(&out));
2890 }
2891 }
2892 search_from = pred_pos + 1;
2893 }
2894 None
2895}
2896
2897fn remove_matching_predicate_by_lexical_value(
2898 block: &str,
2899 predicate: &str,
2900 value: &str,
2901) -> Option<String> {
2902 let norm_value = normalize_ws(value);
2903 let mut search_from = 0;
2904 while let Some(pred_pos) = find_predicate_token(block, search_from, predicate) {
2905 for (obj_start, obj_end) in objects_in_predicate_value(block, pred_pos, predicate) {
2906 let obj_text = block[obj_start..obj_end].trim();
2907 if let Some(lexical) = turtle_literal_lexical_value(obj_text) {
2908 if normalize_ws(&lexical) == norm_value {
2909 let (remove_start, remove_end) =
2910 extend_removal_span(block, pred_pos, predicate, obj_start, obj_end);
2911 let mut out = String::new();
2912 out.push_str(&block[..remove_start]);
2913 out.push_str(&block[remove_end..]);
2914 return Some(cleanup_block_separators(&out));
2915 }
2916 }
2917 }
2918 search_from = pred_pos + 1;
2919 }
2920 None
2921}
2922
2923fn text_contains_entity(
2924 text: &str,
2925 entity_iri: &str,
2926 namespaces: &BTreeMap<String, String>,
2927) -> bool {
2928 let namespaces = crate::span::namespaces_for_text(text, namespaces);
2929 let mut needles = vec![entity_iri.to_string(), format!("<{entity_iri}>")];
2930 if let Some(default_ns) = namespaces.get("") {
2931 if entity_iri.starts_with(default_ns.as_str()) {
2932 let local = &entity_iri[default_ns.len()..];
2933 if is_valid_pn_local(local) {
2934 needles.push(format!(":{local}"));
2935 }
2936 }
2937 }
2938 if let Some((prefix, ns)) = best_namespace_match(entity_iri, &namespaces) {
2939 let local = &entity_iri[ns.len()..];
2940 if is_valid_pn_local(local) {
2941 needles.push(format!("{prefix}:{local}"));
2942 }
2943 }
2944 text.lines().any(|line| {
2945 let trimmed = line.trim_start();
2946 needles.iter().any(|needle| line_starts_with_subject(trimmed, needle))
2947 })
2948}
2949
2950fn line_starts_with_subject(trimmed: &str, subject: &str) -> bool {
2951 trimmed == subject
2952 || trimmed.starts_with(&format!("{subject} "))
2953 || trimmed.starts_with(&format!("{subject}\t"))
2954 || trimmed.starts_with(&format!("{subject};"))
2955 || trimmed.starts_with(&format!("{subject}."))
2956}
2957
2958pub fn is_safe_iri(iri: &str) -> bool {
2960 if iri.is_empty() {
2961 return false;
2962 }
2963 !iri.chars().any(|c| {
2964 c.is_control()
2965 || c.is_whitespace()
2966 || matches!(c, '<' | '>' | '"' | '{' | '}' | '|' | '^' | '`' | '\\')
2967 })
2968}
2969
2970pub(crate) fn is_valid_pn_local(local: &str) -> bool {
2972 if local.is_empty() {
2973 return false;
2974 }
2975 local.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '~'))
2976 && !local.starts_with('.')
2977 && !local.ends_with('.')
2978}
2979
2980fn iri_to_turtle_term(iri: &str, namespaces: &BTreeMap<String, String>) -> Result<String> {
2981 iri_to_turtle_term_impl(iri, namespaces)
2982}
2983
2984pub(crate) fn iri_to_turtle_term_impl(
2985 iri: &str,
2986 namespaces: &BTreeMap<String, String>,
2987) -> Result<String> {
2988 if !is_safe_iri(iri) {
2989 return Err(OwlError::PatchInvalid(format!(
2990 "IRI contains characters that cannot be safely written to Turtle: {iri:?}"
2991 )));
2992 }
2993 if iri == "http://www.w3.org/2002/07/owl#Thing" {
2994 return Ok("owl:Thing".to_string());
2995 }
2996
2997 if let Some((prefix, ns)) = best_namespace_match(iri, namespaces) {
2998 let local = &iri[ns.len()..];
2999 if is_valid_pn_local(local) {
3000 return Ok(format!("{prefix}:{local}"));
3001 }
3002 }
3003 Ok(format!("<{iri}>"))
3004}
3005
3006pub(crate) fn best_namespace_match<'a>(
3007 iri: &str,
3008 namespaces: &'a BTreeMap<String, String>,
3009) -> Option<(&'a str, &'a str)> {
3010 let mut best: Option<(&str, &str, usize)> = None;
3011 for (prefix, ns) in namespaces {
3012 if prefix.is_empty() || !iri.starts_with(ns.as_str()) {
3013 continue;
3014 }
3015 let len = ns.len();
3016 if best.as_ref().is_none_or(|(_, _, best_len)| len > *best_len) {
3017 best = Some((prefix.as_str(), ns.as_str(), len));
3018 }
3019 }
3020 best.map(|(prefix, ns, _)| (prefix, ns))
3021}
3022
3023#[cfg(test)]
3024mod tests {
3025 use super::*;
3026
3027 fn ex_ns() -> BTreeMap<String, String> {
3028 BTreeMap::from([
3029 ("ex".to_string(), "http://example.org/people#".to_string()),
3030 ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
3031 ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
3032 ])
3033 }
3034
3035 #[test]
3039 fn add_label_to_existing_class() {
3040 let ttl = include_str!("../../../fixtures/example.ttl");
3041 let patches = vec![PatchOp::AddLabel {
3042 entity_iri: "http://example.org/people#Person".to_string(),
3043 value: "Human".to_string(),
3044 }];
3045 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3046 let preview = result.preview_text.expect("preview");
3047 assert_eq!(
3048 preview.matches("rdfs:label \"Human\"").count(),
3049 1,
3050 "must insert label exactly once"
3051 );
3052 }
3053
3054 #[test]
3055 fn add_label_not_blocked_by_label_text_in_long_comment() {
3056 let ttl = r#"@prefix ex: <http://example.org/ex#> .
3057@prefix owl: <http://www.w3.org/2002/07/owl#> .
3058@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3059
3060ex:Foo a owl:Class ;
3061 rdfs:comment """Documentation mentions rdfs:label \"Bar\" syntax.""" .
3062"#;
3063 let ns = BTreeMap::from([
3064 ("ex".to_string(), "http://example.org/ex#".to_string()),
3065 ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
3066 ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
3067 ]);
3068 let patches = vec![PatchOp::AddLabel {
3069 entity_iri: "http://example.org/ex#Foo".to_string(),
3070 value: "Bar".to_string(),
3071 }];
3072 let result = apply_patches_to_text(ttl, &patches, true, &ns).expect("patch");
3073 let preview = result.preview_text.expect("preview");
3074 assert!(preview.contains("rdfs:label \"Bar\""));
3075 }
3076
3077 #[test]
3078 fn add_label_duplicate_returns_error() {
3079 let ttl = r#"@prefix ex: <http://example.org/ex#> .
3080@prefix owl: <http://www.w3.org/2002/07/owl#> .
3081@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3082
3083ex:Foo a owl:Class ;
3084 rdfs:label "Bar" .
3085"#;
3086 let ns = BTreeMap::from([
3087 ("ex".to_string(), "http://example.org/ex#".to_string()),
3088 ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
3089 ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
3090 ]);
3091 let patches = vec![PatchOp::AddLabel {
3092 entity_iri: "http://example.org/ex#Foo".to_string(),
3093 value: "Bar".to_string(),
3094 }];
3095 let result = apply_patches_to_text(ttl, &patches, true, &ns).expect("patch");
3096 assert!(!result.applied);
3097 assert!(result.diagnostics.iter().any(|d| d.message.contains("duplicate")));
3098 }
3099
3100 #[test]
3101 fn remove_subclass_does_not_leave_orphaned_predicate() {
3102 let ttl = include_str!("../../../fixtures/example.ttl");
3103 let patches = vec![PatchOp::RemoveSubClassOf {
3104 entity_iri: "http://example.org/people#Person".to_string(),
3105 parent_iri: "http://example.org/people#Thing".to_string(),
3106 }];
3107 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3108 let preview = result.preview_text.expect("preview");
3109 assert!(!preview.contains("rdfs:subClassOf."));
3110 assert!(!preview.contains("rdfs:subClassOf ;"));
3111 assert!(!preview.contains("ex:Person rdfs:subClassOf"));
3112 }
3113
3114 #[test]
3115 fn add_subclass_of_no_duplicate_when_trailing_triple_exists() {
3116 let ttl = include_str!("../../../fixtures/example.ttl");
3117 let patches = vec![PatchOp::AddSubClassOf {
3118 entity_iri: "http://example.org/people#Person".to_string(),
3119 parent_iri: "http://example.org/people#Thing".to_string(),
3120 }];
3121 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3122 assert!(
3123 result.preview_text.is_none(),
3124 "must not duplicate subclass axiom already present as trailing triple"
3125 );
3126 }
3127
3128 #[test]
3129 fn remove_import_from_trailing_triple() {
3130 let ttl = r#"@prefix owl: <http://www.w3.org/2002/07/owl#> .
3131
3132<http://example.org/people> a owl:Ontology .
3133<http://example.org/people> owl:imports <http://example.org/other> .
3134"#;
3135 let ns = ex_ns();
3136 let result = apply_patches_to_text(
3137 ttl,
3138 &[PatchOp::RemoveImport {
3139 ontology_iri: "http://example.org/people".to_string(),
3140 import_iri: "http://example.org/other".to_string(),
3141 }],
3142 true,
3143 &ns,
3144 )
3145 .expect("remove trailing import");
3146 let preview = result.preview_text.expect("preview");
3147 assert!(!preview.contains("owl:imports"));
3148 }
3149
3150 #[test]
3151 fn remove_label_from_single_quoted_literal() {
3152 let ttl = r#"@prefix ex: <http://example.org/people#> .
3153@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3154@prefix owl: <http://www.w3.org/2002/07/owl#> .
3155
3156ex:Person a owl:Class ;
3157 rdfs:label 'Human' .
3158"#;
3159 let patches = vec![PatchOp::RemoveLabel {
3160 entity_iri: "http://example.org/people#Person".to_string(),
3161 value: "Human".to_string(),
3162 }];
3163 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3164 let preview = result.preview_text.expect("preview");
3165 assert!(!preview.contains("rdfs:label 'Human'"));
3166 assert!(!preview.contains("'Human'"));
3167 assert!(preview.contains("ex:Person a owl:Class"));
3168 }
3169
3170 #[test]
3171 fn remove_comment_from_long_single_quoted_literal() {
3172 let ttl = r#"@prefix ex: <http://example.org/people#> .
3173@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3174@prefix owl: <http://www.w3.org/2002/07/owl#> .
3175
3176ex:Person a owl:Class ;
3177 rdfs:comment '''A human being.''' .
3178"#;
3179 let patches = vec![PatchOp::RemoveComment {
3180 entity_iri: "http://example.org/people#Person".to_string(),
3181 value: "A human being.".to_string(),
3182 }];
3183 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3184 let preview = result.preview_text.expect("preview");
3185 assert!(!preview.contains("rdfs:comment '''A human being.'''"));
3186 assert!(preview.contains("ex:Person a owl:Class"));
3187 }
3188
3189 #[test]
3190 fn remove_label_ignores_predicate_inside_long_single_quoted_comment() {
3191 let ttl = r#"@prefix ex: <http://example.org/people#> .
3192@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3193@prefix owl: <http://www.w3.org/2002/07/owl#> .
3194
3195ex:Person a owl:Class ;
3196 rdfs:comment '''see rdfs:label usage''' ;
3197 rdfs:label "Name" .
3198"#;
3199 let patches = vec![PatchOp::RemoveLabel {
3200 entity_iri: "http://example.org/people#Person".to_string(),
3201 value: "Name".to_string(),
3202 }];
3203 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3204 let preview = result.preview_text.expect("preview");
3205 assert!(preview.contains("see rdfs:label usage"));
3206 assert!(!preview.contains("rdfs:label \"Name\""));
3207 }
3208
3209 #[test]
3210 fn remove_comment_with_period_in_literal() {
3211 let ttl = r#"@prefix ex: <http://example.org/people#> .
3212@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3213@prefix owl: <http://www.w3.org/2002/07/owl#> .
3214
3215ex:Person a owl:Class ;
3216 rdfs:comment "A human being." .
3217"#;
3218 let patches = vec![PatchOp::RemoveComment {
3219 entity_iri: "http://example.org/people#Person".to_string(),
3220 value: "A human being.".to_string(),
3221 }];
3222 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3223 let preview = result.preview_text.expect("preview");
3224 assert!(!preview.contains("A human being."));
3225 assert!(!preview.contains("rdfs:comment"));
3226 assert!(preview.contains("ex:Person a owl:Class"));
3227 }
3228
3229 #[test]
3230 fn remove_label_ignores_predicate_name_inside_comment() {
3231 let ttl = r#"@prefix ex: <http://example.org/people#> .
3232@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3233@prefix owl: <http://www.w3.org/2002/07/owl#> .
3234
3235ex:Person a owl:Class ;
3236 rdfs:comment "see rdfs:label usage" ;
3237 rdfs:label "Name" .
3238"#;
3239 let patches = vec![PatchOp::RemoveLabel {
3240 entity_iri: "http://example.org/people#Person".to_string(),
3241 value: "Name".to_string(),
3242 }];
3243 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3244 let preview = result.preview_text.expect("preview");
3245 assert!(preview.contains("see rdfs:label usage"));
3246 assert!(!preview.contains("rdfs:label \"Name\""));
3247 }
3248
3249 #[test]
3250 fn create_new_class() {
3251 let ttl = include_str!("../../../fixtures/example.ttl");
3252 let patches = vec![PatchOp::CreateEntity {
3253 entity_iri: "http://example.org/people#Employee".to_string(),
3254 kind: PatchEntityKind::Class,
3255 }];
3256 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3257 assert!(result.preview_text.unwrap().contains("ex:Employee"));
3258 }
3259
3260 #[test]
3261 fn batch_failure_leaves_source_unchanged() {
3262 let ttl = include_str!("../../../fixtures/example.ttl");
3263 let patches = vec![
3264 PatchOp::AddLabel {
3265 entity_iri: "http://example.org/people#Person".to_string(),
3266 value: "Human".to_string(),
3267 },
3268 PatchOp::AddLabel {
3269 entity_iri: "http://example.org/people#NoSuch".to_string(),
3270 value: "X".to_string(),
3271 },
3272 ];
3273 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3274 assert!(!result.diagnostics.is_empty());
3275 assert_eq!(result.preview_text.as_deref(), Some(ttl));
3276 assert!(!result.applied);
3277 }
3278
3279 #[test]
3280 fn crlf_line_offsets_match_byte_positions() {
3281 let ttl = "ex:Foo a owl:Class ;\r\n rdfs:label \"Bar\" .\r\n";
3282 let ns = BTreeMap::from([
3283 ("ex".into(), "http://example.org/".into()),
3284 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
3285 ]);
3286 let range = entity_primary_block_range(ttl, "http://example.org/Foo", &ns).expect("range");
3287 let block = &ttl[range.start as usize..range.end as usize];
3288 assert!(block.contains("rdfs:label"));
3289 assert!(block.trim_end().ends_with('.'));
3290 }
3291
3292 #[test]
3293 fn cleanup_preserves_literal_double_spaces() {
3294 let block = "ex:Foo rdfs:label \"a b\" .";
3295 let cleaned = cleanup_block_separators(block);
3296 assert!(cleaned.contains("\"a b\""));
3297 }
3298
3299 #[test]
3300 fn iri_with_angle_bracket_is_rejected_not_injected() {
3301 let ttl = "@prefix ex: <http://example.org/people#> .\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\n\nex:Person a owl:Class .\n";
3302 let evil =
3303 "http://example.org/people#X> . ex:Pwned a owl:Class . <http://example.org/people#Y";
3304 let patches = vec![PatchOp::CreateEntity {
3305 entity_iri: evil.to_string(),
3306 kind: PatchEntityKind::Class,
3307 }];
3308 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3309 assert!(!result.applied);
3310 assert!(!result.diagnostics.is_empty());
3311 assert_eq!(result.preview_text.as_deref(), Some(ttl));
3312 let produced = result.preview_text.as_deref().unwrap_or("");
3314 assert!(
3315 !produced.contains("ex:Pwned") && !produced.contains("Pwned"),
3316 "malicious local name must not be written: {produced}"
3317 );
3318 assert!(
3319 result.diagnostics.iter().any(|d| d.severity == "error"),
3320 "expected error diagnostic for angle-bracket IRI injection"
3321 );
3322 }
3323
3324 #[test]
3325 fn iri_with_newline_is_rejected() {
3326 let ttl = "@prefix ex: <http://example.org/people#> .\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\n\nex:Person a owl:Class .\n";
3327 let evil = "http://example.org/people#X\n. ex:Injected a owl:Class .\n#";
3328 let patches = vec![PatchOp::CreateEntity {
3329 entity_iri: evil.to_string(),
3330 kind: PatchEntityKind::Class,
3331 }];
3332 let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
3333 assert!(!result.applied);
3334 assert_eq!(result.preview_text.as_deref(), Some(ttl));
3335 }
3336
3337 #[test]
3338 fn longest_namespace_prefix_wins() {
3339 let ns = BTreeMap::from([
3340 ("ex".to_string(), "http://example.org/".to_string()),
3341 ("exfoo".to_string(), "http://example.org/foo/".to_string()),
3342 ]);
3343 let term = iri_to_turtle_term("http://example.org/foo/Bar", &ns).expect("term");
3344 assert_eq!(term, "exfoo:Bar");
3345 }
3346
3347 #[test]
3348 fn slash_in_local_name_uses_angle_brackets() {
3349 let ns = BTreeMap::from([("ex".to_string(), "http://example.org/".to_string())]);
3350 let term = iri_to_turtle_term("http://example.org/foo/Bar", &ns).expect("term");
3351 assert_eq!(term, "<http://example.org/foo/Bar>");
3352 }
3353
3354 #[test]
3355 fn add_disjoint_class_is_idempotent_when_axiom_exists() {
3356 let ttl = include_str!("../../../fixtures/disjoint-classes.ttl");
3357 let ns = BTreeMap::from([
3358 ("ex".to_string(), "http://example.org/org#".to_string()),
3359 ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
3360 ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
3361 ]);
3362 let patches = vec![PatchOp::AddDisjointClass {
3363 entity_iri: "http://example.org/org#Cat".to_string(),
3364 other_iri: "http://example.org/org#Dog".to_string(),
3365 }];
3366 let before = ttl.matches("owl:disjointWith").count();
3367 let result = apply_patches_to_text(ttl, &patches, true, &ns).expect("patch");
3368 let preview = result.preview_text.as_deref().unwrap_or(ttl);
3369 assert_eq!(before, preview.matches("owl:disjointWith").count());
3370 }
3371
3372 fn org_ns() -> BTreeMap<String, String> {
3373 BTreeMap::from([
3374 ("ex".to_string(), "http://example.org/org#".to_string()),
3375 ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
3376 ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
3377 ])
3378 }
3379
3380 #[test]
3381 fn add_property_chain_rejects_class_iris() {
3382 let ttl = include_str!("../../../fixtures/disjoint-classes.ttl");
3383 let ns = org_ns();
3384 let result = apply_patches_to_text(
3385 ttl,
3386 &[PatchOp::AddPropertyChain {
3387 entity_iri: "http://example.org/org#chases".to_string(),
3388 properties: vec![
3389 "http://example.org/org#Cat".to_string(),
3390 "http://example.org/org#Dog".to_string(),
3391 ],
3392 }],
3393 true,
3394 &ns,
3395 )
3396 .expect("patch result");
3397 assert!(!result.diagnostics.is_empty());
3398 assert!(result.diagnostics[0].message.contains("owl:Class"));
3399 }
3400
3401 #[test]
3402 fn url_shaped_annotation_value_is_literal_not_iri() {
3403 let ttl = r#"@prefix ex: <http://example.org/org#> .
3404@prefix owl: <http://www.w3.org/2002/07/owl#> .
3405@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
3406
3407ex:Cat a owl:Class .
3408"#;
3409 let mut ns = org_ns();
3410 ns.insert("skos".to_string(), "http://www.w3.org/2004/02/skos/core#".to_string());
3411 let result = apply_patches_to_text(
3412 ttl,
3413 &[PatchOp::AddAnnotation {
3414 entity_iri: "http://example.org/org#Cat".to_string(),
3415 predicate: "skos:note".to_string(),
3416 value: "https://example.org/docs/guide".to_string(),
3417 }],
3418 true,
3419 &ns,
3420 )
3421 .expect("add url-shaped annotation");
3422 let preview = result.preview_text.expect("preview");
3423 assert!(
3424 preview.contains("skos:note \"https://example.org/docs/guide\""),
3425 "URL-shaped strings must be quoted literals: {preview}"
3426 );
3427 assert!(
3428 !preview.contains("skos:note <https://example.org/docs/guide>"),
3429 "must not write URL-shaped strings as IRI objects: {preview}"
3430 );
3431 }
3432
3433 #[test]
3434 fn bracketed_iri_annotation_value_is_object() {
3435 let ttl = r#"@prefix ex: <http://example.org/org#> .
3436@prefix owl: <http://www.w3.org/2002/07/owl#> .
3437@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
3438
3439ex:Cat a owl:Class .
3440"#;
3441 let mut ns = org_ns();
3442 ns.insert("skos".to_string(), "http://www.w3.org/2004/02/skos/core#".to_string());
3443 let result = apply_patches_to_text(
3444 ttl,
3445 &[PatchOp::AddAnnotation {
3446 entity_iri: "http://example.org/org#Cat".to_string(),
3447 predicate: "skos:exactMatch".to_string(),
3448 value: "<https://example.org/other#Cat>".to_string(),
3449 }],
3450 true,
3451 &ns,
3452 )
3453 .expect("add bracketed IRI annotation");
3454 let preview = result.preview_text.expect("preview");
3455 assert!(
3456 preview.contains("skos:exactMatch <https://example.org/other#Cat>")
3457 || preview.contains("skos:exactMatch ex:"),
3458 "bracketed values should write as IRI objects: {preview}"
3459 );
3460 assert!(!preview.contains("skos:exactMatch \"<https://"));
3461 }
3462
3463 #[test]
3464 fn remove_url_shaped_literal_annotation() {
3465 let ttl = r#"@prefix ex: <http://example.org/org#> .
3466@prefix owl: <http://www.w3.org/2002/07/owl#> .
3467@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
3468
3469ex:Cat a owl:Class ;
3470 skos:note "https://example.org/docs/guide" .
3471"#;
3472 let mut ns = org_ns();
3473 ns.insert("skos".to_string(), "http://www.w3.org/2004/02/skos/core#".to_string());
3474 let result = apply_patches_to_text(
3475 ttl,
3476 &[PatchOp::RemoveAnnotation {
3477 entity_iri: "http://example.org/org#Cat".to_string(),
3478 predicate: "skos:note".to_string(),
3479 value: "https://example.org/docs/guide".to_string(),
3480 }],
3481 true,
3482 &ns,
3483 )
3484 .expect("remove url-shaped literal");
3485 let preview = result.preview_text.expect("preview");
3486 assert!(!preview.contains("https://example.org/docs/guide"));
3487 }
3488
3489 #[test]
3490 fn adversarial_curie_annotation_predicate_is_rejected() {
3491 let ttl = r#"@prefix ex: <http://example.org/org#> .
3492@prefix owl: <http://www.w3.org/2002/07/owl#> .
3493
3494ex:Cat a owl:Class .
3495"#;
3496 let evil = "x:y> a owl:Class . <http://ex.org/z";
3497 let result = apply_patches_to_text(
3498 ttl,
3499 &[PatchOp::AddAnnotation {
3500 entity_iri: "http://example.org/org#Cat".to_string(),
3501 predicate: evil.to_string(),
3502 value: "safe".to_string(),
3503 }],
3504 true,
3505 &org_ns(),
3506 )
3507 .expect("patch call succeeds with diagnostics");
3508 assert!(!result.diagnostics.is_empty());
3509 assert_eq!(result.preview_text.as_deref(), Some(ttl));
3510 assert!(!ttl.contains("owl:Class . <http://ex.org/z"));
3511 let preview = result.preview_text.as_deref().unwrap_or("");
3512 assert!(
3513 !preview.contains("a owl:Class . <http://ex.org/z"),
3514 "predicate breakout must not be written: {preview}"
3515 );
3516 }
3517
3518 #[test]
3519 fn adversarial_curie_ontology_annotation_predicate_is_rejected() {
3520 let ttl = r#"@prefix ex: <http://example.org/org#> .
3521@prefix owl: <http://www.w3.org/2002/07/owl#> .
3522
3523<http://example.org/org> a owl:Ontology .
3524"#;
3525 let evil = "evil:x> ; owl:imports <http://evil>";
3526 let result = apply_patches_to_text(
3527 ttl,
3528 &[PatchOp::AddOntologyAnnotation {
3529 ontology_iri: "http://example.org/org".to_string(),
3530 predicate: evil.to_string(),
3531 value: "note".to_string(),
3532 }],
3533 true,
3534 &org_ns(),
3535 )
3536 .expect("patch call succeeds with diagnostics");
3537 assert!(!result.diagnostics.is_empty());
3538 assert_eq!(result.preview_text.as_deref(), Some(ttl));
3539 let preview = result.preview_text.as_deref().unwrap_or("");
3540 assert!(
3541 !preview.contains("owl:imports <http://evil>"),
3542 "ontology annotation breakout must not be written: {preview}"
3543 );
3544 }
3545
3546 #[test]
3547 fn full_iri_annotation_predicate_still_works() {
3548 let ttl = r#"@prefix ex: <http://example.org/org#> .
3549@prefix owl: <http://www.w3.org/2002/07/owl#> .
3550@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
3551
3552ex:Cat a owl:Class .
3553"#;
3554 let mut ns = org_ns();
3555 ns.insert("skos".to_string(), "http://www.w3.org/2004/02/skos/core#".to_string());
3556 let result = apply_patches_to_text(
3557 ttl,
3558 &[PatchOp::AddAnnotation {
3559 entity_iri: "http://example.org/org#Cat".to_string(),
3560 predicate: "http://www.w3.org/2004/02/skos/core#definition".to_string(),
3561 value: "A feline animal".to_string(),
3562 }],
3563 true,
3564 &ns,
3565 )
3566 .expect("add full-IRI annotation predicate");
3567 let preview = result.preview_text.expect("preview");
3568 assert!(
3569 preview.contains("skos:definition \"A feline animal\"")
3570 || preview.contains(
3571 "<http://www.w3.org/2004/02/skos/core#definition> \"A feline animal\""
3572 ),
3573 "full IRI predicates must still write safely: {preview}"
3574 );
3575 }
3576
3577 #[test]
3578 fn add_prefix_after_existing_prefixes() {
3579 let ttl = "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n\n<http://example.org/test> a owl:Ontology .\n";
3580 let result = apply_patches_to_text(
3581 ttl,
3582 &[PatchOp::AddPrefix {
3583 prefix: "ex".to_string(),
3584 namespace_iri: "http://example.org/test#".to_string(),
3585 }],
3586 true,
3587 &BTreeMap::new(),
3588 )
3589 .expect("add prefix");
3590 let preview = result.preview_text.expect("preview");
3591 assert!(preview.starts_with(
3592 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n@prefix ex: <http://example.org/test#> .\n"
3593 ));
3594 }
3595
3596 #[test]
3597 fn set_ontology_iri_rewrites_angle_bracket_subject() {
3598 let ttl = r#"@prefix owl: <http://www.w3.org/2002/07/owl#> .
3599
3600<http://example.org/old> a owl:Ontology .
3601"#;
3602 let result = apply_patches_to_text(
3603 ttl,
3604 &[PatchOp::SetOntologyIri { ontology_iri: "http://example.org/new".to_string() }],
3605 true,
3606 &BTreeMap::new(),
3607 )
3608 .expect("set ontology iri");
3609 let preview = result.preview_text.expect("preview");
3610 assert!(preview.contains("<http://example.org/new> a owl:Ontology"));
3611 assert!(!preview.contains("<http://example.org/old>"));
3612 assert_eq!(preview.matches("a owl:Ontology").count(), 1);
3613 }
3614
3615 #[test]
3616 fn set_ontology_iri_rewrites_curie_subject_in_place() {
3617 let ttl = r#"@prefix ex: <http://example.org/> .
3618@prefix owl: <http://www.w3.org/2002/07/owl#> .
3619
3620ex:ont a owl:Ontology .
3621"#;
3622 let result = apply_patches_to_text(
3623 ttl,
3624 &[PatchOp::SetOntologyIri { ontology_iri: "http://example.org/new".to_string() }],
3625 true,
3626 &BTreeMap::from([("ex".to_string(), "http://example.org/".to_string())]),
3627 )
3628 .expect("set ontology iri");
3629 let preview = result.preview_text.expect("preview");
3630 assert!(
3631 preview.contains("<http://example.org/new> a owl:Ontology"),
3632 "expected rewritten subject: {preview}"
3633 );
3634 assert!(
3635 !preview.contains("ex:ont a owl:Ontology"),
3636 "original CURIE declaration must be replaced: {preview}"
3637 );
3638 assert_eq!(
3639 preview.matches("a owl:Ontology").count(),
3640 1,
3641 "must not append a second ontology declaration: {preview}"
3642 );
3643 }
3644
3645 #[test]
3646 fn set_ontology_iri_rewrites_curie_in_multiline_ontology_block() {
3647 let ttl = r#"@prefix ex: <http://example.org/> .
3648@prefix owl: <http://www.w3.org/2002/07/owl#> .
3649
3650ex:ont a owl:Ontology ;
3651 owl:versionIRI <http://example.org/ont/1.0> .
3652"#;
3653 let result = apply_patches_to_text(
3654 ttl,
3655 &[PatchOp::SetOntologyIri { ontology_iri: "http://example.org/new".to_string() }],
3656 true,
3657 &BTreeMap::from([("ex".to_string(), "http://example.org/".to_string())]),
3658 )
3659 .expect("set ontology iri");
3660 let preview = result.preview_text.expect("preview");
3661 assert!(preview.contains("<http://example.org/new> a owl:Ontology ;"));
3662 assert!(preview.contains("owl:versionIRI <http://example.org/ont/1.0>"));
3663 assert!(!preview.contains("ex:ont a owl:Ontology"));
3664 assert_eq!(preview.matches("a owl:Ontology").count(), 1);
3665 }
3666
3667 #[test]
3668 fn set_ontology_iri_appends_only_when_no_declaration() {
3669 let ttl = r#"@prefix ex: <http://example.org/> .
3670@prefix owl: <http://www.w3.org/2002/07/owl#> .
3671
3672ex:Foo a owl:Class .
3673"#;
3674 let result = apply_patches_to_text(
3675 ttl,
3676 &[PatchOp::SetOntologyIri { ontology_iri: "http://example.org/new".to_string() }],
3677 true,
3678 &BTreeMap::new(),
3679 )
3680 .expect("set ontology iri");
3681 let preview = result.preview_text.expect("preview");
3682 assert!(preview.contains("<http://example.org/new> a owl:Ontology ."));
3683 assert!(preview.contains("ex:Foo a owl:Class"));
3684 assert_eq!(preview.matches("a owl:Ontology").count(), 1);
3685 }
3686
3687 #[test]
3688 fn set_version_iri_replaces_existing() {
3689 let ttl = r#"@prefix owl: <http://www.w3.org/2002/07/owl#> .
3690
3691<http://example.org/ont> a owl:Ontology ;
3692 owl:versionIRI <http://example.org/ont/1> .
3693"#;
3694 let result = apply_patches_to_text(
3695 ttl,
3696 &[PatchOp::SetVersionIri {
3697 ontology_iri: "http://example.org/ont".to_string(),
3698 version_iri: "http://example.org/ont/2".to_string(),
3699 }],
3700 true,
3701 &BTreeMap::new(),
3702 )
3703 .expect("set version iri");
3704 let preview = result.preview_text.expect("preview");
3705 assert!(
3706 preview.contains("owl:versionIRI <http://example.org/ont/2>"),
3707 "expected new version IRI: {preview}"
3708 );
3709 assert!(
3710 !preview.contains("http://example.org/ont/1"),
3711 "old version IRI must be removed: {preview}"
3712 );
3713 assert_eq!(
3714 preview.matches("owl:versionIRI").count(),
3715 1,
3716 "must keep exactly one versionIRI: {preview}"
3717 );
3718 }
3719
3720 #[test]
3721 fn set_version_iri_repeated_does_not_accumulate() {
3722 let ttl = r#"@prefix owl: <http://www.w3.org/2002/07/owl#> .
3723
3724<http://example.org/ont> a owl:Ontology .
3725"#;
3726 let first = apply_patches_to_text(
3727 ttl,
3728 &[PatchOp::SetVersionIri {
3729 ontology_iri: "http://example.org/ont".to_string(),
3730 version_iri: "http://example.org/ont/1".to_string(),
3731 }],
3732 true,
3733 &BTreeMap::new(),
3734 )
3735 .expect("first set");
3736 let after_first = first.preview_text.expect("preview");
3737 let second = apply_patches_to_text(
3738 &after_first,
3739 &[PatchOp::SetVersionIri {
3740 ontology_iri: "http://example.org/ont".to_string(),
3741 version_iri: "http://example.org/ont/2".to_string(),
3742 }],
3743 true,
3744 &BTreeMap::new(),
3745 )
3746 .expect("second set");
3747 let preview = second.preview_text.expect("preview");
3748 assert!(preview.contains("owl:versionIRI <http://example.org/ont/2>"));
3749 assert!(!preview.contains("http://example.org/ont/1"));
3750 assert_eq!(preview.matches("owl:versionIRI").count(), 1);
3751 }
3752
3753 #[test]
3754 fn remove_prefix_leaves_other_prefixes() {
3755 let ttl = "@prefix ex: <http://example.org/test#> .\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\n\nex:Thing a owl:Class .\n";
3756 let result = apply_patches_to_text(
3757 ttl,
3758 &[PatchOp::RemovePrefix { prefix: "ex".to_string() }],
3759 true,
3760 &BTreeMap::new(),
3761 )
3762 .expect("remove prefix");
3763 let preview = result.preview_text.expect("preview");
3764 assert!(!preview.contains("@prefix ex:"));
3765 assert!(preview.contains("@prefix owl:"));
3766 assert!(preview.contains("ex:Thing a owl:Class"));
3767 }
3768
3769 #[test]
3770 fn set_prefix_updates_uppercase_at_prefix_in_place() {
3771 let ttl = "@PREFIX ex: <http://old.example/> .\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\n\nex:Thing a owl:Class .\n";
3772 let result = apply_patches_to_text(
3773 ttl,
3774 &[PatchOp::SetPrefix {
3775 prefix: "ex".to_string(),
3776 namespace_iri: "http://new.example/".to_string(),
3777 }],
3778 true,
3779 &BTreeMap::new(),
3780 )
3781 .expect("set prefix");
3782 let preview = result.preview_text.expect("preview");
3783 assert!(preview.contains("@PREFIX ex: <http://new.example/> ."));
3784 assert!(!preview.contains("http://old.example/"));
3785 assert_eq!(preview.matches("ex:").count(), 2); assert!(!preview.contains("@prefix ex:"));
3787 }
3788
3789 #[test]
3790 fn set_prefix_updates_sparql_style_prefix_in_place() {
3791 let ttl = "PREFIX ex: <http://old.example/>\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\n\nex:Thing a owl:Class .\n";
3792 let result = apply_patches_to_text(
3793 ttl,
3794 &[PatchOp::SetPrefix {
3795 prefix: "ex".to_string(),
3796 namespace_iri: "http://new.example/".to_string(),
3797 }],
3798 true,
3799 &BTreeMap::new(),
3800 )
3801 .expect("set prefix");
3802 let preview = result.preview_text.expect("preview");
3803 assert!(preview.contains("PREFIX ex: <http://new.example/>"));
3804 assert!(!preview.contains("http://old.example/"));
3805 assert!(!preview.contains("@prefix ex:"));
3806 }
3807
3808 #[test]
3809 fn remove_prefix_recognizes_uppercase_and_sparql_forms() {
3810 let ttl = "@PREFIX ex: <http://example.org/test#> .\nPREFIX owl: <http://www.w3.org/2002/07/owl#>\n\nex:Thing a owl:Class .\n";
3811 let result = apply_patches_to_text(
3812 ttl,
3813 &[
3814 PatchOp::RemovePrefix { prefix: "ex".to_string() },
3815 PatchOp::RemovePrefix { prefix: "owl".to_string() },
3816 ],
3817 true,
3818 &BTreeMap::new(),
3819 )
3820 .expect("remove prefixes");
3821 let preview = result.preview_text.expect("preview");
3822 assert!(!preview.contains("@PREFIX ex:"));
3823 assert!(!preview.contains("PREFIX owl:"));
3824 assert!(preview.contains("ex:Thing a owl:Class"));
3825 }
3826
3827 #[test]
3828 fn add_prefix_detects_duplicate_uppercase_declaration() {
3829 let ttl = "@PREFIX ex: <http://example.org/test#> .\n\nex:Thing a owl:Class .\n";
3830 let result = apply_patches_to_text(
3831 ttl,
3832 &[PatchOp::AddPrefix {
3833 prefix: "ex".to_string(),
3834 namespace_iri: "http://other.example/".to_string(),
3835 }],
3836 true,
3837 &BTreeMap::new(),
3838 )
3839 .expect("apply returns diagnostics on validation failure");
3840 assert!(!result.applied);
3841 assert!(result
3842 .diagnostics
3843 .iter()
3844 .any(|d| d.message.contains("duplicate prefix already present: ex")));
3845 }
3846
3847 #[test]
3848 fn remove_label_matches_language_tagged_literal() {
3849 let ttl = r#"@prefix ex: <http://example.org/> .
3850@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3851@prefix owl: <http://www.w3.org/2002/07/owl#> .
3852
3853ex:Foo a owl:Class ;
3854 rdfs:label "Person"@en .
3855"#;
3856 let ns = BTreeMap::from([
3857 ("ex".into(), "http://example.org/".into()),
3858 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
3859 ("rdfs".into(), "http://www.w3.org/2000/01/rdf-schema#".into()),
3860 ]);
3861 let result = apply_patches_to_text(
3862 ttl,
3863 &[PatchOp::RemoveLabel {
3864 entity_iri: "http://example.org/Foo".into(),
3865 value: "Person".into(),
3866 }],
3867 true,
3868 &ns,
3869 )
3870 .expect("remove lang-tagged label");
3871 let preview = result.preview_text.expect("preview");
3872 assert!(!preview.contains("rdfs:label"));
3873 assert!(!preview.contains("\"Person\"@en"));
3874 }
3875
3876 #[test]
3877 fn add_label_ignores_a_substring_inside_comment() {
3878 let ttl = r#"@prefix ex: <http://example.org/> .
3879@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3880@prefix owl: <http://www.w3.org/2002/07/owl#> .
3881
3882ex:Foo rdfs:comment "was a prototype" .
3883ex:Foo a owl:Class .
3884"#;
3885 let ns = BTreeMap::from([
3886 ("ex".into(), "http://example.org/".into()),
3887 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
3888 ("rdfs".into(), "http://www.w3.org/2000/01/rdf-schema#".into()),
3889 ]);
3890 let result = apply_patches_to_text(
3891 ttl,
3892 &[PatchOp::AddLabel { entity_iri: "http://example.org/Foo".into(), value: "L".into() }],
3893 true,
3894 &ns,
3895 )
3896 .expect("add label");
3897 let preview = result.preview_text.expect("preview");
3898 let class_stmt = preview
3899 .lines()
3900 .find(|l| l.contains("a owl:Class") || l.contains("rdfs:label \"L\""))
3901 .map(str::to_string)
3902 .unwrap_or_default();
3903 assert!(
3904 preview.contains("ex:Foo a owl:Class") && preview.contains("rdfs:label \"L\""),
3905 "label must attach to type statement: {preview}"
3906 );
3907 assert!(
3908 !preview.contains("was a prototype\" ;\n rdfs:label")
3909 && !preview.contains("\"was a prototype\" ;\n rdfs:label"),
3910 "label must not insert into comment statement: {preview}\nclass-ish: {class_stmt}"
3911 );
3912 for line in preview.lines() {
3914 if line.contains("was a prototype") {
3915 assert!(!line.contains("rdfs:label"), "comment line must stay label-free: {line}");
3916 }
3917 }
3918 }
3919
3920 #[test]
3921 fn remove_import_matches_angle_bracket_when_prefix_exists() {
3922 let ttl = r#"@prefix ex: <http://example.org/> .
3923@prefix owl: <http://www.w3.org/2002/07/owl#> .
3924<http://example.org/ont> a owl:Ontology ;
3925 owl:imports <http://example.org/other> .
3926"#;
3927 let ns = BTreeMap::from([
3928 ("ex".into(), "http://example.org/".into()),
3929 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
3930 ]);
3931 let result = apply_patches_to_text(
3932 ttl,
3933 &[PatchOp::RemoveImport {
3934 ontology_iri: "http://example.org/ont".into(),
3935 import_iri: "http://example.org/other".into(),
3936 }],
3937 true,
3938 &ns,
3939 )
3940 .expect("remove import");
3941 let preview = result.preview_text.expect("preview");
3942 assert!(!preview.contains("owl:imports"));
3943 }
3944
3945 #[test]
3946 fn set_ontology_iri_rewrites_rdf_type_declaration() {
3947 let ttl = r#"@prefix owl: <http://www.w3.org/2002/07/owl#> .
3948@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
3949<http://example.org/old> rdf:type owl:Ontology .
3950"#;
3951 let result = apply_patches_to_text(
3952 ttl,
3953 &[PatchOp::SetOntologyIri { ontology_iri: "http://example.org/new".into() }],
3954 true,
3955 &BTreeMap::new(),
3956 )
3957 .expect("set ontology iri");
3958 let preview = result.preview_text.expect("preview");
3959 assert!(
3960 preview.contains("<http://example.org/new> rdf:type owl:Ontology"),
3961 "expected in-place rewrite: {preview}"
3962 );
3963 assert!(!preview.contains("<http://example.org/old>"));
3964 assert_eq!(
3965 preview.matches("owl:Ontology").count(),
3966 1,
3967 "must not append a second ontology declaration: {preview}"
3968 );
3969 }
3970
3971 #[test]
3972 fn remove_data_property_assertion_matches_typed_literal() {
3973 let ttl = r#"@prefix ex: <http://example.org/> .
3974@prefix owl: <http://www.w3.org/2002/07/owl#> .
3975@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
3976ex:ind a owl:NamedIndividual ;
3977 ex:age "42"^^xsd:integer .
3978"#;
3979 let ns = BTreeMap::from([
3980 ("ex".into(), "http://example.org/".into()),
3981 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
3982 ("xsd".into(), "http://www.w3.org/2001/XMLSchema#".into()),
3983 ]);
3984 let result = apply_patches_to_text(
3985 ttl,
3986 &[PatchOp::RemoveDataPropertyAssertion {
3987 entity_iri: "http://example.org/ind".into(),
3988 property_iri: "http://example.org/age".into(),
3989 value: "42".into(),
3990 }],
3991 true,
3992 &ns,
3993 )
3994 .expect("remove typed data assertion");
3995 let preview = result.preview_text.expect("preview");
3996 assert!(!preview.contains("ex:age"));
3997 assert!(!preview.contains("\"42\""));
3998 }
3999
4000 #[test]
4001 fn set_functional_ignores_characteristic_text_inside_comment() {
4002 let ttl = r#"@prefix ex: <http://example.org/> .
4003@prefix owl: <http://www.w3.org/2002/07/owl#> .
4004@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
4005
4006ex:p a owl:ObjectProperty ;
4007 rdfs:comment "Not a owl:FunctionalProperty despite the wording" .
4008"#;
4009 let ns = BTreeMap::from([
4010 ("ex".into(), "http://example.org/".into()),
4011 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4012 ("rdfs".into(), "http://www.w3.org/2000/01/rdf-schema#".into()),
4013 ]);
4014 let result = apply_patches_to_text(
4015 ttl,
4016 &[PatchOp::SetFunctional { entity_iri: "http://example.org/p".into(), value: true }],
4017 true,
4018 &ns,
4019 )
4020 .expect("set functional");
4021 let preview = result.preview_text.expect("preview");
4022 assert!(
4023 preview.contains("a owl:FunctionalProperty")
4024 || preview.contains("owl:FunctionalProperty ;")
4025 || preview.contains("owl:FunctionalProperty\n"),
4026 "must insert a real FunctionalProperty type: {preview}"
4027 );
4028 let without_comment = preview
4030 .lines()
4031 .filter(|l| !l.contains("despite the wording"))
4032 .collect::<Vec<_>>()
4033 .join("\n");
4034 assert!(
4035 without_comment.contains("owl:FunctionalProperty"),
4036 "FunctionalProperty must appear outside the comment: {preview}"
4037 );
4038 }
4039
4040 #[test]
4041 fn property_chain_does_not_false_positive_on_short_prefix_curie() {
4042 let ttl = r#"@prefix ex: <http://example.org/> .
4043@prefix owl: <http://www.w3.org/2002/07/owl#> .
4044ex:Bar a owl:Class .
4045<http://example.org/foo/Bar> a owl:ObjectProperty .
4046"#;
4047 let ns = BTreeMap::from([
4048 ("ex".into(), "http://example.org/".into()),
4049 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4050 ]);
4051 let result = apply_patches_to_text(
4052 ttl,
4053 &[PatchOp::AddPropertyChain {
4054 entity_iri: "http://example.org/foo/Bar".into(),
4055 properties: vec!["http://example.org/foo/Bar".into()],
4056 }],
4057 true,
4058 &ns,
4059 )
4060 .expect("property chain on absolute IRI ObjectProperty");
4061 assert!(
4062 result.diagnostics.is_empty(),
4063 "must not treat colliding short-name Class as the chain member: {:?}",
4064 result.diagnostics
4065 );
4066 let preview = result.preview_text.expect("preview");
4067 assert!(preview.contains("owl:propertyChainAxiom"));
4068 }
4069
4070 #[test]
4071 fn complex_subclass_uses_document_prefixes_when_caller_map_empty() {
4072 let ttl = r#"@prefix ex: <http://example.org/> .
4073@prefix owl: <http://www.w3.org/2002/07/owl#> .
4074@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
4075ex:A a owl:Class .
4076ex:B a owl:Class .
4077ex:p a owl:ObjectProperty .
4078"#;
4079 let empty = BTreeMap::new();
4080 let result = apply_patches_to_text(
4081 ttl,
4082 &[PatchOp::AddComplexSubClassOf {
4083 entity_iri: "http://example.org/A".into(),
4084 manchester: "ex:p some ex:B".into(),
4085 }],
4086 true,
4087 &empty,
4088 )
4089 .expect("patch");
4090 assert!(
4091 result.diagnostics.is_empty(),
4092 "document @prefix must resolve Manchester CURIEs: {:?}",
4093 result.diagnostics
4094 );
4095 let preview = result.preview_text.expect("preview");
4096 assert!(preview.contains("owl:someValuesFrom") || preview.contains("ex:B"));
4097 }
4098
4099 #[test]
4100 fn add_has_key_writes_rdf_list() {
4101 let ttl = r#"@prefix ex: <http://example.org/> .
4102@prefix owl: <http://www.w3.org/2002/07/owl#> .
4103ex:Person a owl:Class .
4104ex:hasSSN a owl:ObjectProperty .
4105ex:hasName a owl:DatatypeProperty .
4106"#;
4107 let ns = BTreeMap::from([
4108 ("ex".into(), "http://example.org/".into()),
4109 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4110 ]);
4111 let result = apply_patches_to_text(
4112 ttl,
4113 &[PatchOp::AddHasKey {
4114 class_iri: "http://example.org/Person".into(),
4115 properties: vec![
4116 "http://example.org/hasSSN".into(),
4117 "http://example.org/hasName".into(),
4118 ],
4119 }],
4120 true,
4121 &ns,
4122 )
4123 .expect("has key");
4124 let preview = result.preview_text.expect("preview");
4125 assert!(preview.contains("owl:hasKey"));
4126 assert!(preview.contains("( ex:hasSSN ex:hasName )") || preview.contains("ex:hasSSN"));
4127 }
4128
4129 #[test]
4130 fn add_and_remove_disjoint_union() {
4131 let ttl = r#"@prefix ex: <http://example.org/> .
4132@prefix owl: <http://www.w3.org/2002/07/owl#> .
4133ex:Color a owl:Class .
4134ex:Red a owl:Class .
4135ex:Blue a owl:Class .
4136"#;
4137 let ns = BTreeMap::from([
4138 ("ex".into(), "http://example.org/".into()),
4139 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4140 ]);
4141 let add = apply_patches_to_text(
4142 ttl,
4143 &[PatchOp::AddDisjointUnion {
4144 class_iri: "http://example.org/Color".into(),
4145 members: vec!["http://example.org/Red".into(), "http://example.org/Blue".into()],
4146 }],
4147 true,
4148 &ns,
4149 )
4150 .expect("add disjoint union");
4151 let preview = add.preview_text.expect("preview");
4152 assert!(preview.contains("owl:disjointUnionOf"));
4153 let removed = apply_patches_to_text(
4154 &preview,
4155 &[PatchOp::RemoveDisjointUnion {
4156 class_iri: "http://example.org/Color".into(),
4157 members: vec!["http://example.org/Red".into(), "http://example.org/Blue".into()],
4158 }],
4159 true,
4160 &ns,
4161 )
4162 .expect("remove disjoint union");
4163 let out = removed.preview_text.expect("preview");
4164 assert!(!out.contains("owl:disjointUnionOf"));
4165 }
4166
4167 #[test]
4168 fn add_inverse_object_properties() {
4169 let ttl = r#"@prefix ex: <http://example.org/> .
4170@prefix owl: <http://www.w3.org/2002/07/owl#> .
4171ex:hasChild a owl:ObjectProperty .
4172ex:hasParent a owl:ObjectProperty .
4173"#;
4174 let ns = BTreeMap::from([
4175 ("ex".into(), "http://example.org/".into()),
4176 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4177 ]);
4178 let result = apply_patches_to_text(
4179 ttl,
4180 &[PatchOp::AddInverseObjectProperties {
4181 property_iri: "http://example.org/hasChild".into(),
4182 inverse_iri: "http://example.org/hasParent".into(),
4183 }],
4184 true,
4185 &ns,
4186 )
4187 .expect("inverse");
4188 let preview = result.preview_text.expect("preview");
4189 assert!(preview.contains("owl:inverseOf") && preview.contains("ex:hasParent"));
4190 }
4191
4192 #[test]
4193 fn add_negative_object_property_assertion() {
4194 let ttl = r#"@prefix ex: <http://example.org/> .
4195@prefix owl: <http://www.w3.org/2002/07/owl#> .
4196@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
4197ex:alice a owl:NamedIndividual .
4198ex:bob a owl:NamedIndividual .
4199ex:knows a owl:ObjectProperty .
4200"#;
4201 let ns = BTreeMap::from([
4202 ("ex".into(), "http://example.org/".into()),
4203 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4204 ("rdf".into(), "http://www.w3.org/1999/02/22-rdf-syntax-ns#".into()),
4205 ]);
4206 let result = apply_patches_to_text(
4207 ttl,
4208 &[PatchOp::AddNegativeObjectPropertyAssertion {
4209 entity_iri: "http://example.org/alice".into(),
4210 property_iri: "http://example.org/knows".into(),
4211 target_iri: "http://example.org/bob".into(),
4212 }],
4213 true,
4214 &ns,
4215 )
4216 .expect("nopa");
4217 let preview = result.preview_text.expect("preview");
4218 assert!(preview.contains("owl:NegativePropertyAssertion"));
4219 assert!(preview.contains("owl:sourceIndividual") && preview.contains("ex:alice"));
4220 assert!(preview.contains("owl:targetIndividual") && preview.contains("ex:bob"));
4221 let removed = apply_patches_to_text(
4222 &preview,
4223 &[PatchOp::RemoveNegativeObjectPropertyAssertion {
4224 entity_iri: "http://example.org/alice".into(),
4225 property_iri: "http://example.org/knows".into(),
4226 target_iri: "http://example.org/bob".into(),
4227 }],
4228 true,
4229 &ns,
4230 )
4231 .expect("remove nopa");
4232 let out = removed.preview_text.expect("preview");
4233 assert!(!out.contains("owl:NegativePropertyAssertion"));
4234 }
4235
4236 #[test]
4237 fn add_same_individual_pairwise() {
4238 let ttl = r#"@prefix ex: <http://example.org/> .
4239@prefix owl: <http://www.w3.org/2002/07/owl#> .
4240ex:a a owl:NamedIndividual .
4241ex:b a owl:NamedIndividual .
4242ex:c a owl:NamedIndividual .
4243"#;
4244 let ns = BTreeMap::from([
4245 ("ex".into(), "http://example.org/".into()),
4246 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4247 ]);
4248 let result = apply_patches_to_text(
4249 ttl,
4250 &[PatchOp::AddSameIndividual {
4251 individuals: vec![
4252 "http://example.org/a".into(),
4253 "http://example.org/b".into(),
4254 "http://example.org/c".into(),
4255 ],
4256 }],
4257 true,
4258 &ns,
4259 )
4260 .expect("same individual");
4261 let preview = result.preview_text.expect("preview");
4262 assert!(preview.contains("owl:sameAs"));
4263 assert!(preview.contains("ex:b") && preview.contains("ex:c"));
4264 }
4265
4266 #[test]
4267 fn add_different_individuals_writes_all_different() {
4268 let ttl = r#"@prefix ex: <http://example.org/> .
4269@prefix owl: <http://www.w3.org/2002/07/owl#> .
4270ex:a a owl:NamedIndividual .
4271ex:b a owl:NamedIndividual .
4272ex:c a owl:NamedIndividual .
4273"#;
4274 let ns = BTreeMap::from([
4275 ("ex".into(), "http://example.org/".into()),
4276 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4277 ]);
4278 let result = apply_patches_to_text(
4279 ttl,
4280 &[PatchOp::AddDifferentIndividuals {
4281 individuals: vec![
4282 "http://example.org/a".into(),
4283 "http://example.org/b".into(),
4284 "http://example.org/c".into(),
4285 ],
4286 }],
4287 true,
4288 &ns,
4289 )
4290 .expect("add different");
4291 let preview = result.preview_text.expect("preview");
4292 assert!(
4293 preview.contains("owl:AllDifferent") && preview.contains("owl:distinctMembers"),
4294 "must emit AllDifferent, got: {preview}"
4295 );
4296 assert!(
4297 preview.contains("ex:a") && preview.contains("ex:b") && preview.contains("ex:c"),
4298 "must list all members: {preview}"
4299 );
4300 assert!(
4302 !preview.contains("owl:differentFrom"),
4303 "should not emit chain-only differentFrom: {preview}"
4304 );
4305 }
4306
4307 #[test]
4308 fn remove_different_individuals_rewrites_all_different_fixture_style() {
4309 let ttl = r#"@prefix ex: <http://example.org/abox#> .
4310@prefix owl: <http://www.w3.org/2002/07/owl#> .
4311ex:alice a owl:NamedIndividual .
4312ex:bob a owl:NamedIndividual .
4313ex:carol a owl:NamedIndividual .
4314[] a owl:AllDifferent ;
4315 owl:distinctMembers ( ex:alice ex:bob ex:carol ) .
4316"#;
4317 let ns = BTreeMap::from([
4318 ("ex".into(), "http://example.org/abox#".into()),
4319 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4320 ]);
4321 let removed = apply_patches_to_text(
4323 ttl,
4324 &[PatchOp::RemoveDifferentIndividuals {
4325 individuals: vec![
4326 "http://example.org/abox#alice".into(),
4327 "http://example.org/abox#bob".into(),
4328 ],
4329 }],
4330 true,
4331 &ns,
4332 )
4333 .expect("remove pair from AllDifferent");
4334 let out = removed.preview_text.expect("preview");
4335 assert!(
4336 !out.contains("owl:AllDifferent"),
4337 "AllDifferent must be gone after removing two of three members: {out}"
4338 );
4339
4340 let rewritten = apply_patches_to_text(
4342 ttl,
4343 &[PatchOp::RemoveDifferentIndividuals {
4344 individuals: vec![
4345 "http://example.org/abox#alice".into(),
4346 "http://example.org/abox#carol".into(),
4347 ],
4348 }],
4349 true,
4350 &ns,
4351 )
4352 .expect("rewrite AllDifferent");
4353 let out2 = rewritten.preview_text.expect("preview");
4355 assert!(!out2.contains("owl:AllDifferent"), "expected drop: {out2}");
4356
4357 let four = r#"@prefix ex: <http://example.org/abox#> .
4358@prefix owl: <http://www.w3.org/2002/07/owl#> .
4359ex:alice a owl:NamedIndividual .
4360ex:bob a owl:NamedIndividual .
4361ex:carol a owl:NamedIndividual .
4362ex:dave a owl:NamedIndividual .
4363[] a owl:AllDifferent ;
4364 owl:distinctMembers ( ex:alice ex:bob ex:carol ex:dave ) .
4365"#;
4366 let keep = apply_patches_to_text(
4367 four,
4368 &[PatchOp::RemoveDifferentIndividuals {
4369 individuals: vec![
4370 "http://example.org/abox#alice".into(),
4371 "http://example.org/abox#bob".into(),
4372 ],
4373 }],
4374 true,
4375 &ns,
4376 )
4377 .expect("rewrite leave two");
4378 let out3 = keep.preview_text.expect("preview");
4379 assert!(
4380 out3.contains("owl:AllDifferent")
4381 && out3.contains("owl:distinctMembers ( ex:carol ex:dave )"),
4382 "must rewrite membership: {out3}"
4383 );
4384 assert!(
4385 !out3.contains("distinctMembers ( ex:alice") && !out3.contains("ex:alice ex:bob"),
4386 "removed members must leave the list: {out3}"
4387 );
4388 }
4389
4390 #[test]
4391 fn remove_different_individuals_fails_closed_on_unexpanded_curie() {
4392 let ttl = r#"@prefix ex: <http://example.org/> .
4393@prefix owl: <http://www.w3.org/2002/07/owl#> .
4394ex:a a owl:NamedIndividual .
4395ex:b a owl:NamedIndividual .
4396ex:d a owl:NamedIndividual .
4397[] a owl:AllDifferent ;
4398 owl:distinctMembers ( ex:a ex:b other:c ex:d ) .
4399"#;
4400 let ns = BTreeMap::from([
4401 ("ex".into(), "http://example.org/".into()),
4402 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4403 ]);
4404 let result = apply_patches_to_text(
4405 ttl,
4406 &[PatchOp::RemoveDifferentIndividuals {
4407 individuals: vec!["http://example.org/a".into(), "http://example.org/b".into()],
4408 }],
4409 true,
4410 &ns,
4411 )
4412 .expect("preview path returns ApplyPatchResult");
4413 assert!(!result.applied, "undeclared other:c must not apply: {:?}", result.diagnostics);
4414 assert!(
4415 result.diagnostics.iter().any(|d| {
4416 d.message.contains("cannot be expanded") || d.message.contains("other:c")
4417 }),
4418 "expected expansion diagnostic: {:?}",
4419 result.diagnostics
4420 );
4421 let preview = result.preview_text.unwrap_or_default();
4422 assert!(
4423 preview.contains("other:c") && preview.contains("owl:AllDifferent"),
4424 "must not drop unexpanded members: {preview}"
4425 );
4426 }
4427
4428 #[test]
4429 fn create_datatype_and_definition() {
4430 let ttl = r#"@prefix ex: <http://example.org/> .
4431@prefix owl: <http://www.w3.org/2002/07/owl#> .
4432@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
4433@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
4434"#;
4435 let ns = BTreeMap::from([
4436 ("ex".into(), "http://example.org/".into()),
4437 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4438 ("rdfs".into(), "http://www.w3.org/2000/01/rdf-schema#".into()),
4439 ("xsd".into(), "http://www.w3.org/2001/XMLSchema#".into()),
4440 ]);
4441 let result = apply_patches_to_text(
4442 ttl,
4443 &[PatchOp::AddDatatypeDefinition {
4444 datatype_iri: "http://example.org/SSN".into(),
4445 manchester: "xsd:string".into(),
4446 }],
4447 true,
4448 &ns,
4449 )
4450 .expect("datatype def");
4451 let preview = result.preview_text.expect("preview");
4452 assert!(preview.contains("rdfs:Datatype") || preview.contains("ex:SSN"));
4453 assert!(preview.contains("owl:equivalentClass") && preview.contains("xsd:string"));
4454 }
4455
4456 #[test]
4457 fn add_axiom_annotation_owl_axiom_block() {
4458 let ttl = r#"@prefix ex: <http://example.org/> .
4459@prefix owl: <http://www.w3.org/2002/07/owl#> .
4460@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
4461@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
4462ex:A a owl:Class ;
4463 rdfs:subClassOf ex:B .
4464ex:B a owl:Class .
4465"#;
4466 let ns = BTreeMap::from([
4467 ("ex".into(), "http://example.org/".into()),
4468 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4469 ("rdfs".into(), "http://www.w3.org/2000/01/rdf-schema#".into()),
4470 ("rdf".into(), "http://www.w3.org/1999/02/22-rdf-syntax-ns#".into()),
4471 ]);
4472 let result = apply_patches_to_text(
4473 ttl,
4474 &[PatchOp::AddAxiomAnnotation {
4475 axiom_op: "sub_class_of".into(),
4476 subject_iri: "http://example.org/A".into(),
4477 related_iri: Some("http://example.org/B".into()),
4478 predicate: "rdfs:comment".into(),
4479 value: "explained".into(),
4480 }],
4481 true,
4482 &ns,
4483 )
4484 .expect("axiom ann");
4485 let preview = result.preview_text.expect("preview");
4486 assert!(preview.contains("owl:Axiom"));
4487 assert!(preview.contains("owl:annotatedSource") && preview.contains("owl:annotatedTarget"));
4488 assert!(preview.contains("explained"));
4489 }
4490
4491 #[test]
4492 fn remove_has_key_matches_reversed_property_list() {
4493 let ttl = r#"@prefix ex: <http://example.org/> .
4495@prefix owl: <http://www.w3.org/2002/07/owl#> .
4496ex:Person a owl:Class ;
4497 owl:hasKey ( ex:p2 ex:p1 ) .
4498ex:p1 a owl:ObjectProperty .
4499ex:p2 a owl:ObjectProperty .
4500"#;
4501 let ns = BTreeMap::from([
4502 ("ex".into(), "http://example.org/".into()),
4503 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4504 ]);
4505 let removed = apply_patches_to_text(
4506 ttl,
4507 &[PatchOp::RemoveHasKey {
4508 class_iri: "http://example.org/Person".into(),
4509 properties: vec!["http://example.org/p1".into(), "http://example.org/p2".into()],
4510 }],
4511 true,
4512 &ns,
4513 )
4514 .expect("remove reversed hasKey");
4515 let out = removed.preview_text.expect("preview");
4516 assert!(!out.contains("owl:hasKey"), "expected hasKey removed: {out}");
4517 }
4518
4519 #[test]
4520 fn add_disjoint_object_properties_emits_full_pairwise_closure() {
4521 let ttl = r#"@prefix ex: <http://example.org/> .
4523@prefix owl: <http://www.w3.org/2002/07/owl#> .
4524ex:p1 a owl:ObjectProperty .
4525ex:p2 a owl:ObjectProperty .
4526ex:p3 a owl:ObjectProperty .
4527"#;
4528 let ns = BTreeMap::from([
4529 ("ex".into(), "http://example.org/".into()),
4530 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4531 ]);
4532 let result = apply_patches_to_text(
4533 ttl,
4534 &[PatchOp::AddDisjointObjectProperties {
4535 properties: vec![
4536 "http://example.org/p1".into(),
4537 "http://example.org/p2".into(),
4538 "http://example.org/p3".into(),
4539 ],
4540 }],
4541 true,
4542 &ns,
4543 )
4544 .expect("add disjoint props");
4545 let preview = result.preview_text.expect("preview");
4546 let count = preview.matches("owl:propertyDisjointWith").count();
4548 assert_eq!(count, 3, "expected 3 pairwise disjoint triples, got {count}: {preview}");
4549 }
4550
4551 #[test]
4552 fn remove_axiom_annotation_exact_value_not_substring() {
4553 let ttl = r#"@prefix ex: <http://example.org/> .
4555@prefix owl: <http://www.w3.org/2002/07/owl#> .
4556@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
4557@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
4558ex:A a owl:Class ;
4559 rdfs:subClassOf ex:B .
4560ex:B a owl:Class .
4561[
4562 a owl:Axiom ;
4563 owl:annotatedSource ex:A ;
4564 owl:annotatedProperty rdfs:subClassOf ;
4565 owl:annotatedTarget ex:B ;
4566 rdfs:comment "xy"
4567] .
4568[
4569 a owl:Axiom ;
4570 owl:annotatedSource ex:A ;
4571 owl:annotatedProperty rdfs:subClassOf ;
4572 owl:annotatedTarget ex:B ;
4573 rdfs:comment "x"
4574] .
4575"#;
4576 let ns = BTreeMap::from([
4577 ("ex".into(), "http://example.org/".into()),
4578 ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
4579 ("rdfs".into(), "http://www.w3.org/2000/01/rdf-schema#".into()),
4580 ("rdf".into(), "http://www.w3.org/1999/02/22-rdf-syntax-ns#".into()),
4581 ]);
4582 let result = apply_patches_to_text(
4583 ttl,
4584 &[PatchOp::RemoveAxiomAnnotation {
4585 axiom_op: "sub_class_of".into(),
4586 subject_iri: "http://example.org/A".into(),
4587 related_iri: Some("http://example.org/B".into()),
4588 predicate: "rdfs:comment".into(),
4589 value: "x".into(),
4590 }],
4591 true,
4592 &ns,
4593 )
4594 .expect("remove exact x");
4595 assert!(result.diagnostics.is_empty(), "unexpected diagnostics: {:?}", result.diagnostics);
4596 let preview = result.preview_text.expect("preview should change");
4597 assert!(preview.contains("\"xy\""), "must keep xy block: {preview}");
4598 assert!(!preview.contains("rdfs:comment \"x\""), "must remove exact x: {preview}");
4599 }
4600}