Skip to main content

strixonomy_owl/
patch.rs

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/// A single authoring patch operation (v0.4 Turtle scope).
14#[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    // TBox / Keys / RBox (v0.22)
215    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    // ABox (v0.22)
280    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    // Datatype (v0.22)
313    AddDatatypeDefinition {
314        datatype_iri: String,
315        manchester: String,
316    },
317    RemoveDatatypeDefinition {
318        datatype_iri: String,
319        manchester: String,
320    },
321    // Axiom annotations (v0.22)
322    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    /// SWRL rule authoring (v0.23) — stores rule as JSON via strixonomy:swrlRule.
337    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
380/// Apply patches to a document on disk (Turtle span surgery or XML re-serialize).
381pub 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
420/// Atomically replace `path` with `contents`, preserving existing permission bits (#422).
421pub fn atomic_write(path: &Path, contents: &str) -> Result<()> {
422    Ok(strixonomy_core::atomic_write(path, contents)?)
423}
424
425/// Apply patches to in-memory Turtle text.
426pub 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    // Compact JSON so the literal stays on one Turtle line.
822    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                        // Fall back to exact / compacted string match for non-JSON literals.
859                        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
885/// Extract the JSON string literal from an `strixonomy:swrlRule` triple line for `ontology_iri`.
886fn 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
1058/// Byte length of the subject token on a line that declares an ontology type.
1059///
1060/// Accepts absolute IRI subjects (`<…>`) and prefixed names (`ex:ont`, `:ont`).
1061/// Recognizes `a` / `rdf:type` with `owl:Ontology` or the absolute Ontology IRI.
1062fn ontology_declaration_subject_len(trimmed: &str) -> Option<usize> {
1063    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('@') {
1064        return None;
1065    }
1066    // SPARQL-style PREFIX lines (no leading @).
1067    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        // Prefixed name / default-prefix CURIE / blank-node label.
1078        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
1158/// Treat annotation values as IRI objects only when explicitly marked (`<iri>`) or a known CURIE.
1159/// URL-shaped plain strings default to quoted literals.
1160fn 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        // Full IRIs and non-CURIE terms go through IRI safety checks. Malformed
1318        // CURIE-shaped strings (injection payloads) fail `is_safe_iri` here instead
1319        // of being emitted verbatim.
1320        iri_to_turtle_term(predicate, namespaces)
1321    }
1322}
1323
1324/// Return `term` when it is a known-prefix CURIE with a safe Turtle PN_LOCAL.
1325///
1326/// Rejects `http:`/`https:` (those are IRIs), unknown prefixes, and locals with
1327/// characters that would break out of a Turtle predicate position.
1328fn 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    // Trailing `subject a type .` form
1377    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
1385/// True when `block` already has `type_term` as an `a` / `rdf:type` object (token-aware).
1386fn 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                // Also accept absolute Ontology-style type IRIs for common owl:* terms.
1497                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    // Prefer exact serialized order first (common case).
1594    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    // Fall back to set equality so Inspector reorder / reverse property lists match (#351).
1601    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    // Full pairwise closure (not windows(2)) — required for n-ary Disjoint*Properties (#394).
1660    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
1749/// Serialize DifferentIndividuals as OWL 2 RDF `owl:AllDifferent` + `owl:distinctMembers`.
1750fn 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, /*exact*/ true)? {
1766        return Ok(());
1767    }
1768    // Also skip if a bracket-style AllDifferent with the same member set already exists.
1769    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    // Prefer rewriting/removing owl:AllDifferent (+ owl:members / owl:distinctMembers).
1789    while remove_or_rewrite_one_all_different(text, individuals, &ns)? {
1790        changed = true;
1791    }
1792    // Also clear legacy pairwise owl:differentFrom (full pairwise closure, not windows).
1793    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            // Drop the whole axiom (also covers exact-match remove).
1870            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        // Rewrite distinctMembers list in place.
1882        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
1894/// Ranges covering `[] a owl:AllDifferent … .` statements and `[ … AllDifferent … ] .` blocks.
1895fn 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        // Subject-style blank node (`[] a owl:AllDifferent … .`) — check before empty `[]`
1906        // is swallowed as a bracket pair.
1907        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        // Bracket-style blank node: `[ rdf:type owl:AllDifferent ; … ]`
1923        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        // Prefixed name / blank / other token until whitespace.
2059        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        // Default prefix `:`
2078        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                    // Also drop a leading blank line if present.
2206                    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    // `find_predicate_token` skips nested `[...]` content; strip the outer blank-node brackets.
2390    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    // Same single-path insertion as multiline axioms (no double-insert).
2504    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
2591/// Remove a predicate/object where the object is an IRI that may appear as CURIE or `<IRI>`.
2592fn 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
2618/// Surface forms an IRI may take in Turtle (absolute, CURIE, default-prefix).
2619fn 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
2700/// Extend removal to cover the object, and the predicate when it would be left empty.
2701fn 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
2760/// Find `predicate` as a Turtle token outside strings, IRIs, comments, and brackets.
2761fn 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
2958/// Reject IRIs that would break Turtle `<...>` terms or inject syntax.
2959pub 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
2970/// True when `local` is a valid Turtle PN_LOCAL (simplified).
2971pub(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    // Catalog/Horned reparse oracles for success-path patches live in
3036    // tests/owl_patch_oracles.rs (apply_and_reindex).
3037
3038    #[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        // Injection must not appear in any produced text (preview equals original).
3313        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); // declaration + CURIE use
3786        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        // Stronger: comment line must not gain a label predicate.
3913        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        // Ensure the characteristic is not only inside the comment string.
4029        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        // Non-adjacent pair A≠C must appear in the same list (not windows-only triples).
4301        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        // Inspector-style remove of one projected pair strips those members; carol alone → drop axiom.
4322        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        // Rewrite path: remove only carol from a fresh copy → alice+bob remain.
4341        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        // alice+carol remove leaves bob alone → axiom dropped ( <2 members ).
4354        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        // #351 — HasKey property order is a set for authoring UX.
4494        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        // #394 — n-ary DisjointObjectProperties needs all pairs, not windows(2).
4522        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        // Full pairwise for 3 properties = 3 triples (p1-p2, p1-p3, p2-p3).
4547        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        // #349 — "x" must not delete the "xy" axiom annotation block.
4554        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}