Skip to main content

ontocore_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, short_name_from_iri, ByteRange,
5};
6use ontocore_core::{read_to_string_capped, OntologyFormat, MAX_FILE_BYTES};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::fs;
10use std::io::Write;
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14/// A single authoring patch operation (v0.4 Turtle scope).
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(tag = "op", rename_all = "snake_case")]
17pub enum PatchOp {
18    CreateEntity { entity_iri: String, kind: PatchEntityKind },
19    DeleteEntity { entity_iri: String },
20    SetLabel { entity_iri: String, value: String },
21    AddLabel { entity_iri: String, value: String },
22    RemoveLabel { entity_iri: String, value: String },
23    SetComment { entity_iri: String, value: String },
24    AddComment { entity_iri: String, value: String },
25    RemoveComment { entity_iri: String, value: String },
26    AddSubClassOf { entity_iri: String, parent_iri: String },
27    RemoveSubClassOf { entity_iri: String, parent_iri: String },
28    AddComplexSubClassOf { entity_iri: String, manchester: String },
29    RemoveComplexSubClassOf { entity_iri: String, manchester: String },
30    AddEquivalentClass { entity_iri: String, manchester: String },
31    RemoveEquivalentClass { entity_iri: String, manchester: String },
32    SetEquivalentClass { entity_iri: String, manchester: String },
33    SetDeprecated { entity_iri: String, value: bool },
34    AddDisjointClass { entity_iri: String, other_iri: String },
35    RemoveDisjointClass { entity_iri: String, other_iri: String },
36    AddImport { ontology_iri: String, import_iri: String },
37    RemoveImport { ontology_iri: String, import_iri: String },
38    AddDomain { entity_iri: String, class_iri: String },
39    RemoveDomain { entity_iri: String, class_iri: String },
40    AddRange { entity_iri: String, range_iri: String },
41    RemoveRange { entity_iri: String, range_iri: String },
42    SetFunctional { entity_iri: String, value: bool },
43    SetInverseFunctional { entity_iri: String, value: bool },
44    SetTransitive { entity_iri: String, value: bool },
45    SetSymmetric { entity_iri: String, value: bool },
46    SetAsymmetric { entity_iri: String, value: bool },
47    SetReflexive { entity_iri: String, value: bool },
48    SetIrreflexive { entity_iri: String, value: bool },
49    AddPropertyChain { entity_iri: String, properties: Vec<String> },
50    RemovePropertyChain { entity_iri: String, properties: Vec<String> },
51    AddClassAssertion { entity_iri: String, class_iri: String },
52    RemoveClassAssertion { entity_iri: String, class_iri: String },
53    AddObjectPropertyAssertion { entity_iri: String, property_iri: String, target_iri: String },
54    RemoveObjectPropertyAssertion { entity_iri: String, property_iri: String, target_iri: String },
55    AddDataPropertyAssertion { entity_iri: String, property_iri: String, value: String },
56    RemoveDataPropertyAssertion { entity_iri: String, property_iri: String, value: String },
57    AddAnnotation { entity_iri: String, predicate: String, value: String },
58    RemoveAnnotation { entity_iri: String, predicate: String, value: String },
59}
60
61#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "snake_case")]
63pub enum PatchEntityKind {
64    Class,
65    ObjectProperty,
66    DataProperty,
67    AnnotationProperty,
68    Individual,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PatchDiagnostic {
73    pub severity: String,
74    pub message: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ApplyPatchResult {
79    pub applied: bool,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub preview_text: Option<String>,
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub diagnostics: Vec<PatchDiagnostic>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub document_path: Option<String>,
86}
87
88/// Apply patches to a document on disk (Turtle only).
89pub fn apply_patches(
90    document_path: &Path,
91    patches: &[PatchOp],
92    preview_only: bool,
93    namespaces: &BTreeMap<String, String>,
94) -> Result<ApplyPatchResult> {
95    let format = OntologyFormat::from_extension(
96        document_path.extension().and_then(|e| e.to_str()).unwrap_or(""),
97    );
98    if format != OntologyFormat::Turtle {
99        return Err(OwlError::UnsupportedFormat(format!(
100            "write-back supports Turtle (.ttl) only, got {}",
101            format.as_str()
102        )));
103    }
104
105    let source = read_to_string_capped(document_path, MAX_FILE_BYTES).map_err(OwlError::Core)?;
106    let mut result = apply_patches_to_text(&source, patches, preview_only, namespaces)?;
107    result.document_path = Some(document_path.display().to_string());
108
109    if result.applied && !preview_only {
110        if let Some(text) = &result.preview_text {
111            atomic_write(document_path, text)?;
112        }
113    }
114    Ok(result)
115}
116
117pub fn atomic_write(path: &Path, contents: &str) -> Result<()> {
118    let parent =
119        path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
120    fs::create_dir_all(parent)?;
121    let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
122    let stem = path.file_name().and_then(|s| s.to_str()).unwrap_or("file");
123    let tmp_path = parent.join(format!(".ontocode-{stem}-{nanos}.tmp"));
124    {
125        let mut file = fs::File::create(&tmp_path)?;
126        file.write_all(contents.as_bytes())?;
127        file.sync_all()?;
128    }
129    replace_file(&tmp_path, path)?;
130    Ok(())
131}
132
133/// Replace `path` with `tmp_path` (tmp is consumed). Works on Windows where `rename` cannot
134/// overwrite an existing destination.
135fn replace_file(tmp_path: &Path, path: &Path) -> std::io::Result<()> {
136    match fs::rename(tmp_path, path) {
137        Ok(()) => Ok(()),
138        Err(_) if path.exists() => {
139            // Windows (and some network FS): rename refuses to replace. Move the existing
140            // file aside, then rename; restore on failure.
141            let bak_path = tmp_path.with_extension("bak");
142            fs::rename(path, &bak_path)?;
143            match fs::rename(tmp_path, path) {
144                Ok(()) => {
145                    let _ = fs::remove_file(&bak_path);
146                    Ok(())
147                }
148                Err(rename_err) => {
149                    let _ = fs::rename(&bak_path, path);
150                    let _ = fs::remove_file(tmp_path);
151                    Err(rename_err)
152                }
153            }
154        }
155        Err(e) => {
156            let _ = fs::remove_file(tmp_path);
157            Err(e)
158        }
159    }
160}
161
162/// Apply patches to in-memory Turtle text.
163pub fn apply_patches_to_text(
164    source: &str,
165    patches: &[PatchOp],
166    preview_only: bool,
167    namespaces: &BTreeMap<String, String>,
168) -> Result<ApplyPatchResult> {
169    let mut working = source.to_string();
170    let mut diagnostics = Vec::new();
171
172    for patch in patches {
173        match apply_one_patch(&mut working, patch, namespaces) {
174            Ok(()) => {}
175            Err(e) => {
176                diagnostics.push(PatchDiagnostic {
177                    severity: "error".to_string(),
178                    message: e.to_string(),
179                });
180                return Ok(ApplyPatchResult {
181                    applied: false,
182                    preview_text: Some(source.to_string()),
183                    diagnostics,
184                    document_path: None,
185                });
186            }
187        }
188    }
189
190    let changed = working != source;
191    Ok(ApplyPatchResult {
192        applied: changed && !preview_only,
193        preview_text: if changed { Some(working) } else { None },
194        diagnostics,
195        document_path: None,
196    })
197}
198
199fn apply_one_patch(
200    text: &mut String,
201    patch: &PatchOp,
202    namespaces: &BTreeMap<String, String>,
203) -> Result<()> {
204    match patch {
205        PatchOp::CreateEntity { entity_iri, kind } => {
206            create_entity(text, entity_iri, *kind, namespaces)
207        }
208        PatchOp::DeleteEntity { entity_iri } => delete_entity(text, entity_iri, namespaces),
209        PatchOp::SetLabel { entity_iri, value } => {
210            remove_all_predicate_any_statement(text, entity_iri, "rdfs:label", namespaces)?;
211            add_annotation_triple(text, entity_iri, "rdfs:label", value, namespaces)
212        }
213        PatchOp::AddLabel { entity_iri, value } => {
214            add_annotation_triple(text, entity_iri, "rdfs:label", value, namespaces)
215        }
216        PatchOp::RemoveLabel { entity_iri, value } => {
217            remove_matching_predicate_any(text, entity_iri, "rdfs:label", value, namespaces)
218        }
219        PatchOp::SetComment { entity_iri, value } => {
220            remove_all_predicate_any_statement(text, entity_iri, "rdfs:comment", namespaces)?;
221            add_annotation_triple(text, entity_iri, "rdfs:comment", value, namespaces)
222        }
223        PatchOp::AddComment { entity_iri, value } => {
224            add_annotation_triple(text, entity_iri, "rdfs:comment", value, namespaces)
225        }
226        PatchOp::RemoveComment { entity_iri, value } => {
227            remove_matching_predicate_any(text, entity_iri, "rdfs:comment", value, namespaces)
228        }
229        PatchOp::AddSubClassOf { entity_iri, parent_iri } => {
230            add_subclass_triple(text, entity_iri, parent_iri, namespaces)
231        }
232        PatchOp::RemoveSubClassOf { entity_iri, parent_iri } => {
233            remove_subclass_triple(text, entity_iri, parent_iri, namespaces)
234        }
235        PatchOp::AddComplexSubClassOf { entity_iri, manchester } => {
236            add_complex_axiom(text, entity_iri, manchester, "rdfs:subClassOf", namespaces)
237        }
238        PatchOp::RemoveComplexSubClassOf { entity_iri, manchester } => {
239            remove_complex_axiom(text, entity_iri, manchester, "rdfs:subClassOf", namespaces)
240        }
241        PatchOp::AddEquivalentClass { entity_iri, manchester } => {
242            add_complex_axiom(text, entity_iri, manchester, "owl:equivalentClass", namespaces)
243        }
244        PatchOp::RemoveEquivalentClass { entity_iri, manchester } => {
245            remove_complex_axiom(text, entity_iri, manchester, "owl:equivalentClass", namespaces)
246        }
247        PatchOp::SetEquivalentClass { entity_iri, manchester } => {
248            remove_predicate_triples(text, entity_iri, "owl:equivalentClass", namespaces)?;
249            add_complex_axiom(text, entity_iri, manchester, "owl:equivalentClass", namespaces)
250        }
251        PatchOp::SetDeprecated { entity_iri, value } => {
252            if *value {
253                add_object_triple(text, entity_iri, "owl:deprecated", "true", namespaces)
254            } else {
255                remove_predicate_triples(text, entity_iri, "owl:deprecated", namespaces)
256            }
257        }
258        PatchOp::AddDisjointClass { entity_iri, other_iri } => {
259            let other = iri_to_turtle_term(other_iri, namespaces)?;
260            add_object_triple(text, entity_iri, "owl:disjointWith", &other, namespaces)
261        }
262        PatchOp::RemoveDisjointClass { entity_iri, other_iri } => {
263            remove_disjoint_triple(text, entity_iri, other_iri, namespaces)
264        }
265        PatchOp::AddImport { ontology_iri, import_iri } => {
266            let import_term = iri_to_turtle_term(import_iri, namespaces)?;
267            add_object_triple(text, ontology_iri, "owl:imports", &import_term, namespaces)
268        }
269        PatchOp::RemoveImport { ontology_iri, import_iri } => {
270            let import_term = iri_to_turtle_term(import_iri, namespaces)?;
271            remove_predicate_object(text, ontology_iri, "owl:imports", &import_term, namespaces)
272        }
273        PatchOp::AddDomain { entity_iri, class_iri } => {
274            let class = iri_to_turtle_term(class_iri, namespaces)?;
275            add_object_triple(text, entity_iri, "rdfs:domain", &class, namespaces)
276        }
277        PatchOp::RemoveDomain { entity_iri, class_iri } => {
278            let class = iri_to_turtle_term(class_iri, namespaces)?;
279            remove_predicate_object_any_statement(
280                text,
281                entity_iri,
282                "rdfs:domain",
283                &class,
284                namespaces,
285            )
286        }
287        PatchOp::AddRange { entity_iri, range_iri } => {
288            let range = iri_to_turtle_term(range_iri, namespaces)?;
289            add_object_triple(text, entity_iri, "rdfs:range", &range, namespaces)
290        }
291        PatchOp::RemoveRange { entity_iri, range_iri } => {
292            let range = iri_to_turtle_term(range_iri, namespaces)?;
293            remove_predicate_object_any_statement(
294                text,
295                entity_iri,
296                "rdfs:range",
297                &range,
298                namespaces,
299            )
300        }
301        PatchOp::SetFunctional { entity_iri, value } => set_property_characteristic(
302            text,
303            entity_iri,
304            "owl:FunctionalProperty",
305            *value,
306            namespaces,
307        ),
308        PatchOp::SetInverseFunctional { entity_iri, value } => set_property_characteristic(
309            text,
310            entity_iri,
311            "owl:InverseFunctionalProperty",
312            *value,
313            namespaces,
314        ),
315        PatchOp::SetTransitive { entity_iri, value } => set_property_characteristic(
316            text,
317            entity_iri,
318            "owl:TransitiveProperty",
319            *value,
320            namespaces,
321        ),
322        PatchOp::SetSymmetric { entity_iri, value } => set_property_characteristic(
323            text,
324            entity_iri,
325            "owl:SymmetricProperty",
326            *value,
327            namespaces,
328        ),
329        PatchOp::SetAsymmetric { entity_iri, value } => set_property_characteristic(
330            text,
331            entity_iri,
332            "owl:AsymmetricProperty",
333            *value,
334            namespaces,
335        ),
336        PatchOp::SetReflexive { entity_iri, value } => set_property_characteristic(
337            text,
338            entity_iri,
339            "owl:ReflexiveProperty",
340            *value,
341            namespaces,
342        ),
343        PatchOp::SetIrreflexive { entity_iri, value } => set_property_characteristic(
344            text,
345            entity_iri,
346            "owl:IrreflexiveProperty",
347            *value,
348            namespaces,
349        ),
350        PatchOp::AddPropertyChain { entity_iri, properties } => {
351            add_property_chain(text, entity_iri, properties, namespaces)
352        }
353        PatchOp::RemovePropertyChain { entity_iri, properties } => {
354            remove_property_chain(text, entity_iri, properties, namespaces)
355        }
356        PatchOp::AddClassAssertion { entity_iri, class_iri } => {
357            let class = iri_to_turtle_term(class_iri, namespaces)?;
358            add_type_triple(text, entity_iri, &class, namespaces)
359        }
360        PatchOp::RemoveClassAssertion { entity_iri, class_iri } => {
361            let class = iri_to_turtle_term(class_iri, namespaces)?;
362            remove_type_triple(text, entity_iri, &class, namespaces)
363        }
364        PatchOp::AddObjectPropertyAssertion { entity_iri, property_iri, target_iri } => {
365            let prop = iri_to_turtle_term(property_iri, namespaces)?;
366            let target = iri_to_turtle_term(target_iri, namespaces)?;
367            add_property_assertion_triple(text, entity_iri, &prop, &target, namespaces)
368        }
369        PatchOp::RemoveObjectPropertyAssertion { entity_iri, property_iri, target_iri } => {
370            let prop = iri_to_turtle_term(property_iri, namespaces)?;
371            let target = iri_to_turtle_term(target_iri, namespaces)?;
372            remove_predicate_object_any_statement(text, entity_iri, &prop, &target, namespaces)
373        }
374        PatchOp::AddDataPropertyAssertion { entity_iri, property_iri, value } => {
375            let prop = iri_to_turtle_term(property_iri, namespaces)?;
376            add_data_property_assertion(text, entity_iri, &prop, value, namespaces)
377        }
378        PatchOp::RemoveDataPropertyAssertion { entity_iri, property_iri, value } => {
379            let prop = iri_to_turtle_term(property_iri, namespaces)?;
380            let escaped = escape_turtle_string(value);
381            let object = format!("\"{escaped}\"");
382            remove_predicate_object_any_statement(text, entity_iri, &prop, &object, namespaces)
383        }
384        PatchOp::AddAnnotation { entity_iri, predicate, value } => {
385            if predicate == "rdfs:label" || predicate.ends_with("#label") {
386                add_annotation_triple(text, entity_iri, "rdfs:label", value, namespaces)
387            } else if predicate == "rdfs:comment" || predicate.ends_with("#comment") {
388                add_annotation_triple(text, entity_iri, "rdfs:comment", value, namespaces)
389            } else if value.starts_with("http://") || value.starts_with("https://") {
390                let pred = predicate_to_term(predicate, namespaces)?;
391                let obj = iri_to_turtle_term(value, namespaces)?;
392                add_object_triple(text, entity_iri, &pred, &obj, namespaces)
393            } else {
394                let pred = predicate_to_term(predicate, namespaces)?;
395                add_annotation_triple(text, entity_iri, &pred, value, namespaces)
396            }
397        }
398        PatchOp::RemoveAnnotation { entity_iri, predicate, value } => {
399            if predicate == "rdfs:label" || predicate.ends_with("#label") {
400                remove_matching_predicate_any(text, entity_iri, "rdfs:label", value, namespaces)
401            } else if predicate == "rdfs:comment" || predicate.ends_with("#comment") {
402                remove_matching_predicate_any(text, entity_iri, "rdfs:comment", value, namespaces)
403            } else if value.starts_with("http://") || value.starts_with("https://") {
404                let pred = predicate_to_term(predicate, namespaces)?;
405                let obj = iri_to_turtle_term(value, namespaces)?;
406                remove_predicate_object_any_statement(text, entity_iri, &pred, &obj, namespaces)
407            } else {
408                let pred = predicate_to_term(predicate, namespaces)?;
409                remove_matching_predicate_any(text, entity_iri, &pred, value, namespaces)
410            }
411        }
412    }
413}
414
415fn create_entity(
416    text: &mut String,
417    entity_iri: &str,
418    kind: PatchEntityKind,
419    namespaces: &BTreeMap<String, String>,
420) -> Result<()> {
421    if text_contains_entity(text, entity_iri, namespaces) {
422        return Err(OwlError::EntityExists(entity_iri.to_string()));
423    }
424    let subject = iri_to_turtle_term(entity_iri, namespaces)?;
425    let type_term = match kind {
426        PatchEntityKind::Class => "owl:Class",
427        PatchEntityKind::ObjectProperty => "owl:ObjectProperty",
428        PatchEntityKind::DataProperty => "owl:DatatypeProperty",
429        PatchEntityKind::AnnotationProperty => "owl:AnnotationProperty",
430        PatchEntityKind::Individual => "owl:NamedIndividual",
431    };
432    let block = format!("\n{subject} a {type_term} .\n");
433    if !text.ends_with('\n') {
434        text.push('\n');
435    }
436    text.push_str(&block);
437    Ok(())
438}
439
440fn delete_entity(
441    text: &mut String,
442    entity_iri: &str,
443    namespaces: &BTreeMap<String, String>,
444) -> Result<()> {
445    let namespaces = crate::span::namespaces_for_text(text, namespaces);
446    let short = short_name_from_iri(entity_iri);
447    let mut ranges = all_entity_statement_ranges(text, entity_iri, &short, &namespaces);
448    if ranges.is_empty() {
449        return Err(OwlError::EntityNotFound(entity_iri.to_string()));
450    }
451    ranges.sort_by_key(|r| r.start);
452    for range in ranges.into_iter().rev() {
453        replace_range(text, range, "");
454    }
455    Ok(())
456}
457
458fn add_annotation_triple(
459    text: &mut String,
460    entity_iri: &str,
461    predicate: &str,
462    value: &str,
463    namespaces: &BTreeMap<String, String>,
464) -> Result<()> {
465    if !text_contains_entity(text, entity_iri, namespaces) {
466        return Err(OwlError::EntityNotFound(entity_iri.to_string()));
467    }
468    let escaped = escape_turtle_string(value);
469    let triple = format!("    {predicate} \"{escaped}\" ;\n");
470    insert_into_entity_block(text, entity_iri, &triple, namespaces, true)
471}
472
473fn add_object_triple(
474    text: &mut String,
475    entity_iri: &str,
476    predicate: &str,
477    object: &str,
478    namespaces: &BTreeMap<String, String>,
479) -> Result<()> {
480    if !text_contains_entity(text, entity_iri, namespaces) {
481        return Err(OwlError::EntityNotFound(entity_iri.to_string()));
482    }
483    let triple = format!("    {predicate} {object} ;\n");
484    insert_into_entity_block(text, entity_iri, &triple, namespaces, false)
485}
486
487fn add_complex_axiom(
488    text: &mut String,
489    entity_iri: &str,
490    manchester: &str,
491    predicate: &str,
492    namespaces: &BTreeMap<String, String>,
493) -> Result<()> {
494    let parsed = parse_class_expression(manchester, namespaces)?;
495    let triple = class_expression_to_turtle_fragment(&parsed.expression, predicate, namespaces)?;
496    insert_multiline_into_entity_block(text, entity_iri, &triple, namespaces)
497}
498
499fn remove_complex_axiom(
500    text: &mut String,
501    entity_iri: &str,
502    manchester: &str,
503    predicate: &str,
504    namespaces: &BTreeMap<String, String>,
505) -> Result<()> {
506    let parsed = parse_class_expression(manchester, namespaces)?;
507    let object_value =
508        crate::manchester::class_expression_to_turtle_value(&parsed.expression, namespaces, 0)?;
509    remove_predicate_object(text, entity_iri, predicate, &object_value, namespaces)
510}
511
512fn insert_multiline_into_entity_block(
513    text: &mut String,
514    entity_iri: &str,
515    insertion: &str,
516    namespaces: &BTreeMap<String, String>,
517) -> Result<()> {
518    let range = entity_primary_range(text, entity_iri, namespaces)?;
519    let block = &text[range.start as usize..range.end as usize];
520    let trimmed = block.trim_end();
521    let mut new_block = block.to_string();
522    if trimmed.ends_with('.') {
523        if let Some(pos) = new_block.trim_end().rfind('.') {
524            let base = new_block[..pos].trim_end();
525            new_block = format!("{base} ;\n{insertion}.");
526        }
527    } else if !trimmed.ends_with(';') {
528        new_block.push_str(" ;\n");
529        new_block.push_str(insertion);
530    } else {
531        new_block.push_str(insertion);
532    }
533    replace_range(text, range, &new_block);
534    Ok(())
535}
536
537fn add_subclass_triple(
538    text: &mut String,
539    entity_iri: &str,
540    parent_iri: &str,
541    namespaces: &BTreeMap<String, String>,
542) -> Result<()> {
543    let parent = iri_to_turtle_term(parent_iri, namespaces)?;
544    add_object_triple(text, entity_iri, "rdfs:subClassOf", &parent, namespaces)
545}
546
547fn remove_subclass_triple(
548    text: &mut String,
549    entity_iri: &str,
550    parent_iri: &str,
551    namespaces: &BTreeMap<String, String>,
552) -> Result<()> {
553    let parent = iri_to_turtle_term(parent_iri, namespaces)?;
554    remove_predicate_object_any_statement(text, entity_iri, "rdfs:subClassOf", &parent, namespaces)
555}
556
557fn remove_disjoint_triple(
558    text: &mut String,
559    entity_iri: &str,
560    other_iri: &str,
561    namespaces: &BTreeMap<String, String>,
562) -> Result<()> {
563    let other = iri_to_turtle_term(other_iri, namespaces)?;
564    remove_predicate_object_any_statement(text, entity_iri, "owl:disjointWith", &other, namespaces)
565}
566
567fn predicate_to_term(predicate: &str, namespaces: &BTreeMap<String, String>) -> Result<String> {
568    if predicate.contains(':') && !predicate.starts_with("http") {
569        Ok(predicate.to_string())
570    } else {
571        iri_to_turtle_term(predicate, namespaces)
572    }
573}
574
575fn set_property_characteristic(
576    text: &mut String,
577    entity_iri: &str,
578    characteristic: &str,
579    value: bool,
580    namespaces: &BTreeMap<String, String>,
581) -> Result<()> {
582    if value {
583        add_type_triple(text, entity_iri, characteristic, namespaces)
584    } else {
585        remove_type_triple(text, entity_iri, characteristic, namespaces)
586    }
587}
588
589fn add_type_triple(
590    text: &mut String,
591    entity_iri: &str,
592    type_term: &str,
593    namespaces: &BTreeMap<String, String>,
594) -> Result<()> {
595    if !text_contains_entity(text, entity_iri, namespaces) {
596        return Err(OwlError::EntityNotFound(entity_iri.to_string()));
597    }
598    let subject = iri_to_turtle_term(entity_iri, namespaces)?;
599    let ns = crate::span::namespaces_for_text(text, namespaces);
600    if let Some(range) = entity_primary_block_range(text, entity_iri, &ns) {
601        let block = &text[range.start as usize..range.end as usize];
602        if block.contains(&format!("a {type_term}"))
603            || block.contains(&format!("a owl:NamedIndividual, {type_term}"))
604            || block.contains(&format!(", {type_term}"))
605        {
606            return Ok(());
607        }
608        let insertion = format!("    a {type_term} ;\n");
609        return insert_into_entity_block(text, entity_iri, &insertion, namespaces, false);
610    }
611    // Trailing `subject a type .` form
612    let triple = format!("\n{subject} a {type_term} .\n");
613    if !text.ends_with('\n') {
614        text.push('\n');
615    }
616    text.push_str(&triple);
617    Ok(())
618}
619
620fn remove_type_triple(
621    text: &mut String,
622    entity_iri: &str,
623    type_term: &str,
624    namespaces: &BTreeMap<String, String>,
625) -> Result<()> {
626    remove_predicate_object_any_statement(text, entity_iri, "a", type_term, namespaces)
627}
628
629fn add_property_assertion_triple(
630    text: &mut String,
631    entity_iri: &str,
632    property_term: &str,
633    target_term: &str,
634    namespaces: &BTreeMap<String, String>,
635) -> Result<()> {
636    if !text_contains_entity(text, entity_iri, namespaces) {
637        return Err(OwlError::EntityNotFound(entity_iri.to_string()));
638    }
639    let triple = format!("    {property_term} {target_term} ;\n");
640    insert_into_entity_block(text, entity_iri, &triple, namespaces, false)
641}
642
643fn add_data_property_assertion(
644    text: &mut String,
645    entity_iri: &str,
646    property_term: &str,
647    value: &str,
648    namespaces: &BTreeMap<String, String>,
649) -> Result<()> {
650    if !text_contains_entity(text, entity_iri, namespaces) {
651        return Err(OwlError::EntityNotFound(entity_iri.to_string()));
652    }
653    let escaped = escape_turtle_string(value);
654    let triple = format!("    {property_term} \"{escaped}\" ;\n");
655    insert_into_entity_block(text, entity_iri, &triple, namespaces, false)
656}
657
658fn entity_declared_as(
659    text: &str,
660    entity_iri: &str,
661    owl_type: &str,
662    namespaces: &BTreeMap<String, String>,
663) -> bool {
664    if !text_contains_entity(text, entity_iri, namespaces) {
665        return false;
666    }
667    let namespaces = crate::span::namespaces_for_text(text, namespaces);
668    let short = short_name_from_iri(entity_iri);
669    let mut needles = vec![entity_iri.to_string(), format!("<{entity_iri}>")];
670    for (prefix, ns) in &namespaces {
671        if entity_iri.starts_with(ns) {
672            needles.push(format!("{prefix}:{short}"));
673        }
674    }
675    text.lines().any(|line| {
676        let trimmed = line.trim_start();
677        let is_subject = needles.iter().any(|needle| line_starts_with_subject(trimmed, needle));
678        is_subject
679            && (trimmed.contains(" a ") || trimmed.contains("\ta"))
680            && trimmed.contains(owl_type)
681    })
682}
683
684fn validate_property_chain_members(
685    text: &str,
686    properties: &[String],
687    namespaces: &BTreeMap<String, String>,
688) -> Result<()> {
689    const INVALID_TYPES: &[&str] =
690        &["owl:Class", "owl:NamedIndividual", "owl:DatatypeProperty", "owl:AnnotationProperty"];
691    for iri in properties {
692        for owl_type in INVALID_TYPES {
693            if entity_declared_as(text, iri, owl_type, namespaces) {
694                return Err(OwlError::PatchInvalid(format!(
695                    "property chain member {iri} is declared as {owl_type}, expected owl:ObjectProperty"
696                )));
697            }
698        }
699    }
700    Ok(())
701}
702
703fn add_property_chain(
704    text: &mut String,
705    entity_iri: &str,
706    properties: &[String],
707    namespaces: &BTreeMap<String, String>,
708) -> Result<()> {
709    if properties.is_empty() {
710        return Err(OwlError::PatchInvalid(
711            "property chain must have at least one property".into(),
712        ));
713    }
714    validate_property_chain_members(text, properties, namespaces)?;
715    let terms: Vec<String> =
716        properties.iter().map(|p| iri_to_turtle_term(p, namespaces)).collect::<Result<Vec<_>>>()?;
717    let chain_obj = format!("( {} )", terms.join(" "));
718    add_object_triple(text, entity_iri, "owl:propertyChainAxiom", &chain_obj, namespaces)
719}
720
721fn remove_property_chain(
722    text: &mut String,
723    entity_iri: &str,
724    properties: &[String],
725    namespaces: &BTreeMap<String, String>,
726) -> Result<()> {
727    let terms: Vec<String> =
728        properties.iter().map(|p| iri_to_turtle_term(p, namespaces)).collect::<Result<Vec<_>>>()?;
729    let chain_obj = format!("( {} )", terms.join(" "));
730    remove_predicate_object_any_statement(
731        text,
732        entity_iri,
733        "owl:propertyChainAxiom",
734        &chain_obj,
735        namespaces,
736    )
737}
738
739fn remove_predicate_triples(
740    text: &mut String,
741    entity_iri: &str,
742    predicate: &str,
743    namespaces: &BTreeMap<String, String>,
744) -> Result<()> {
745    remove_all_predicate_any_statement(text, entity_iri, predicate, namespaces)
746}
747
748fn remove_predicate_object(
749    text: &mut String,
750    entity_iri: &str,
751    predicate: &str,
752    object_value: &str,
753    namespaces: &BTreeMap<String, String>,
754) -> Result<()> {
755    remove_predicate_object_any_statement(text, entity_iri, predicate, object_value, namespaces)
756}
757
758fn remove_matching_predicate_any(
759    text: &mut String,
760    entity_iri: &str,
761    predicate: &str,
762    value: &str,
763    namespaces: &BTreeMap<String, String>,
764) -> Result<()> {
765    let escaped = escape_turtle_string(value.trim_matches('"'));
766    let object = format!("\"{escaped}\"");
767    remove_predicate_object_any_statement(text, entity_iri, predicate, &object, namespaces)
768}
769
770fn remove_all_predicate_any_statement(
771    text: &mut String,
772    entity_iri: &str,
773    predicate: &str,
774    namespaces: &BTreeMap<String, String>,
775) -> Result<()> {
776    loop {
777        let ns = crate::span::namespaces_for_text(text, namespaces);
778        let short = short_name_from_iri(entity_iri);
779        let ranges = all_entity_statement_ranges(text, entity_iri, &short, &ns);
780        if ranges.is_empty() {
781            return Ok(());
782        }
783        let mut removed = false;
784        for range in ranges {
785            let block = &text[range.start as usize..range.end as usize];
786            let new_block = remove_all_predicate_objects(block, predicate);
787            if new_block != block {
788                replace_range(text, range, &new_block);
789                removed = true;
790                break;
791            }
792        }
793        if !removed {
794            return Ok(());
795        }
796    }
797}
798
799fn insert_into_entity_block(
800    text: &mut String,
801    entity_iri: &str,
802    insertion: &str,
803    namespaces: &BTreeMap<String, String>,
804    duplicate_is_error: bool,
805) -> Result<()> {
806    if let Some((predicate, object)) = parse_simple_insertion(insertion) {
807        if entity_has_predicate_object(text, entity_iri, &predicate, &object, namespaces) {
808            if duplicate_is_error {
809                return Err(OwlError::PatchInvalid(format!(
810                    "duplicate {predicate} axiom already present: {object}"
811                )));
812            }
813            return Ok(());
814        }
815    }
816    // Same single-path insertion as multiline axioms (no double-insert).
817    insert_multiline_into_entity_block(text, entity_iri, insertion, namespaces)
818}
819
820fn parse_simple_insertion(insertion: &str) -> Option<(String, String)> {
821    let line = insertion.lines().next()?.trim().trim_end_matches(';').trim();
822    let mut parts = line.splitn(2, char::is_whitespace);
823    let predicate = parts.next()?.to_string();
824    let object = parts.next()?.trim().to_string();
825    if predicate.is_empty() || object.is_empty() {
826        return None;
827    }
828    Some((predicate, object))
829}
830
831fn entity_has_predicate_object(
832    text: &str,
833    entity_iri: &str,
834    predicate: &str,
835    object_value: &str,
836    namespaces: &BTreeMap<String, String>,
837) -> bool {
838    let ns = crate::span::namespaces_for_text(text, namespaces);
839    let short = short_name_from_iri(entity_iri);
840    all_entity_statement_ranges(text, entity_iri, &short, &ns).into_iter().any(|range| {
841        let block = &text[range.start as usize..range.end as usize];
842        block_has_matching_predicate_object(block, predicate, object_value)
843    })
844}
845
846fn block_has_matching_predicate_object(block: &str, predicate: &str, object_value: &str) -> bool {
847    remove_matching_predicate_object(block, predicate, object_value).is_some()
848}
849
850fn replace_range(text: &mut String, range: ByteRange, replacement: &str) {
851    let start = range.start as usize;
852    let end = range.end.min(text.len() as u64) as usize;
853    text.replace_range(start..end, replacement);
854}
855
856fn escape_turtle_string(value: &str) -> String {
857    let mut out = String::with_capacity(value.len());
858    for ch in value.chars() {
859        match ch {
860            '\\' => out.push_str("\\\\"),
861            '"' => out.push_str("\\\""),
862            '\n' => out.push_str("\\n"),
863            '\r' => out.push_str("\\r"),
864            '\t' => out.push_str("\\t"),
865            c => out.push(c),
866        }
867    }
868    out
869}
870
871fn entity_primary_range(
872    text: &str,
873    entity_iri: &str,
874    namespaces: &BTreeMap<String, String>,
875) -> Result<ByteRange> {
876    let ns = crate::span::namespaces_for_text(text, namespaces);
877    entity_primary_block_range(text, entity_iri, &ns)
878        .ok_or_else(|| OwlError::EntityNotFound(entity_iri.to_string()))
879}
880
881fn remove_predicate_object_any_statement(
882    text: &mut String,
883    entity_iri: &str,
884    predicate: &str,
885    object_value: &str,
886    namespaces: &BTreeMap<String, String>,
887) -> Result<()> {
888    let ns = crate::span::namespaces_for_text(text, namespaces);
889    let short = short_name_from_iri(entity_iri);
890    let ranges = all_entity_statement_ranges(text, entity_iri, &short, &ns);
891    if ranges.is_empty() {
892        return Err(OwlError::EntityNotFound(entity_iri.to_string()));
893    }
894    for range in ranges {
895        let block = &text[range.start as usize..range.end as usize];
896        if let Some(new_block) = remove_matching_predicate_object(block, predicate, object_value) {
897            replace_range(text, range, &new_block);
898            return Ok(());
899        }
900    }
901    Err(OwlError::ManchesterInvalid(format!("no matching {predicate} axiom")))
902}
903
904fn normalize_ws(s: &str) -> String {
905    s.split_whitespace().collect::<Vec<_>>().join(" ")
906}
907
908fn bracket_end_index(text: &str, bracket_start: usize) -> Option<usize> {
909    let bytes = text.as_bytes();
910    if bytes.get(bracket_start) != Some(&b'[') {
911        return None;
912    }
913    let mut depth = 0i32;
914    let mut in_string = false;
915    let mut in_iri = false;
916    let mut in_comment = false;
917    let mut escape = false;
918    let mut i = bracket_start;
919    while i < bytes.len() {
920        let b = bytes[i];
921        if in_comment {
922            if b == b'\n' {
923                in_comment = false;
924            }
925            i += 1;
926            continue;
927        }
928        if in_string {
929            if escape {
930                escape = false;
931            } else if b == b'\\' {
932                escape = true;
933            } else if b == b'"' {
934                in_string = false;
935            }
936            i += 1;
937            continue;
938        }
939        if in_iri {
940            if b == b'>' {
941                in_iri = false;
942            }
943            i += 1;
944            continue;
945        }
946        match b {
947            b'#' => in_comment = true,
948            b'"' => in_string = true,
949            b'<' => in_iri = true,
950            b'[' => depth += 1,
951            b']' => {
952                depth -= 1;
953                if depth == 0 {
954                    return Some(i + 1);
955                }
956            }
957            _ => {}
958        }
959        i += 1;
960    }
961    None
962}
963
964/// Extend removal to cover the object, and the predicate when it would be left empty.
965fn extend_removal_span(
966    block: &str,
967    pred_pos: usize,
968    predicate: &str,
969    obj_start: usize,
970    obj_end: usize,
971) -> (usize, usize) {
972    let objects = objects_in_predicate_value(block, pred_pos, predicate);
973    let is_only_object = objects.len() == 1;
974
975    let mut start = if is_only_object { pred_pos } else { obj_start };
976    while start > 0 && block.as_bytes()[start - 1].is_ascii_whitespace() {
977        start -= 1;
978    }
979    if !is_only_object && start > 0 && block.as_bytes()[start - 1] == b',' {
980        start -= 1;
981        while start > 0 && block.as_bytes()[start - 1].is_ascii_whitespace() {
982            start -= 1;
983        }
984    }
985
986    let mut end = obj_end;
987    while end < block.len() && block.as_bytes()[end].is_ascii_whitespace() {
988        end += 1;
989    }
990    if end < block.len() && (block.as_bytes()[end] == b',' || block.as_bytes()[end] == b';') {
991        end += 1;
992    }
993    (start, end)
994}
995
996fn cleanup_block_separators(block: &str) -> String {
997    let mut lines: Vec<&str> = block.lines().collect();
998    while lines.last().is_some_and(|l| l.trim().is_empty()) {
999        lines.pop();
1000    }
1001    lines.join("\n").replace(";\n    ;", ";\n").replace(",\n        ,", ",\n")
1002}
1003
1004fn remove_all_predicate_objects(block: &str, predicate: &str) -> String {
1005    let mut result = block.to_string();
1006    while let Some(next) = remove_first_predicate_object(&result, predicate) {
1007        result = next;
1008    }
1009    cleanup_block_separators(&result)
1010}
1011
1012fn remove_first_predicate_object(block: &str, predicate: &str) -> Option<String> {
1013    let pred_pos = find_predicate_token(block, 0, predicate)?;
1014    let (obj_start, obj_end) =
1015        objects_in_predicate_value(block, pred_pos, predicate).first().copied()?;
1016    let (remove_start, remove_end) =
1017        extend_removal_span(block, pred_pos, predicate, obj_start, obj_end);
1018    let mut out = String::new();
1019    out.push_str(&block[..remove_start]);
1020    out.push_str(&block[remove_end..]);
1021    Some(cleanup_block_separators(&out))
1022}
1023
1024/// Find `predicate` as a Turtle token outside strings, IRIs, comments, and brackets.
1025fn find_predicate_token(block: &str, search_from: usize, predicate: &str) -> Option<usize> {
1026    let bytes = block.as_bytes();
1027    let pred_bytes = predicate.as_bytes();
1028    if pred_bytes.is_empty() || search_from >= bytes.len() {
1029        return None;
1030    }
1031    let mut i = search_from;
1032    let mut in_string = false;
1033    let mut in_iri = false;
1034    let mut in_comment = false;
1035    let mut escape = false;
1036    let mut bracket_depth = 0i32;
1037    while i + pred_bytes.len() <= bytes.len() {
1038        let b = bytes[i];
1039        if in_comment {
1040            if b == b'\n' {
1041                in_comment = false;
1042            }
1043            i += 1;
1044            continue;
1045        }
1046        if in_string {
1047            if escape {
1048                escape = false;
1049            } else if b == b'\\' {
1050                escape = true;
1051            } else if b == b'"' {
1052                in_string = false;
1053            }
1054            i += 1;
1055            continue;
1056        }
1057        if in_iri {
1058            if b == b'>' {
1059                in_iri = false;
1060            }
1061            i += 1;
1062            continue;
1063        }
1064        match b {
1065            b'#' => {
1066                in_comment = true;
1067                i += 1;
1068                continue;
1069            }
1070            b'"' => {
1071                in_string = true;
1072                i += 1;
1073                continue;
1074            }
1075            b'<' => {
1076                in_iri = true;
1077                i += 1;
1078                continue;
1079            }
1080            b'[' => {
1081                bracket_depth += 1;
1082                i += 1;
1083                continue;
1084            }
1085            b']' => {
1086                bracket_depth = bracket_depth.saturating_sub(1);
1087                i += 1;
1088                continue;
1089            }
1090            _ => {}
1091        }
1092        if bracket_depth == 0 && bytes[i..].starts_with(pred_bytes) {
1093            let after = i + pred_bytes.len();
1094            let before_ok = i == 0
1095                || !bytes[i - 1].is_ascii_alphanumeric()
1096                    && bytes[i - 1] != b':'
1097                    && bytes[i - 1] != b'_';
1098            let after_ok = after >= bytes.len()
1099                || bytes[after].is_ascii_whitespace()
1100                || bytes[after] == b';'
1101                || bytes[after] == b'.'
1102                || bytes[after] == b',';
1103            if before_ok && after_ok {
1104                return Some(i);
1105            }
1106        }
1107        i += 1;
1108    }
1109    None
1110}
1111
1112fn find_named_object_end(block: &str, obj_start: usize) -> Option<usize> {
1113    use crate::span::is_turtle_terminating_dot;
1114    let bytes = block.as_bytes();
1115    let mut i = obj_start;
1116    let mut in_string = false;
1117    let mut in_iri = false;
1118    let mut escape = false;
1119    while i < bytes.len() {
1120        let b = bytes[i];
1121        if in_string {
1122            if escape {
1123                escape = false;
1124            } else if b == b'\\' {
1125                escape = true;
1126            } else if b == b'"' {
1127                in_string = false;
1128            }
1129            i += 1;
1130            continue;
1131        }
1132        if in_iri {
1133            if b == b'>' {
1134                in_iri = false;
1135            }
1136            i += 1;
1137            continue;
1138        }
1139        match b {
1140            b'"' => in_string = true,
1141            b'<' => in_iri = true,
1142            b',' | b';' => return Some(i),
1143            b'.' if is_turtle_terminating_dot(bytes, i) => return Some(i),
1144            _ => {}
1145        }
1146        i += 1;
1147    }
1148    Some(block.len())
1149}
1150
1151fn objects_in_predicate_value(
1152    block: &str,
1153    pred_pos: usize,
1154    predicate: &str,
1155) -> Vec<(usize, usize)> {
1156    let list_start = pred_pos + predicate.len();
1157    let mut objects = Vec::new();
1158    let mut i = list_start;
1159    loop {
1160        let rest = block.get(i..).unwrap_or("").trim_start();
1161        if rest.is_empty() || rest.starts_with(';') || rest.starts_with('.') {
1162            break;
1163        }
1164        i += block[i..].len() - rest.len();
1165        if block.as_bytes().get(i) == Some(&b'[') {
1166            if let Some(end) = bracket_end_index(block, i) {
1167                objects.push((i, end));
1168                i = end;
1169            } else {
1170                break;
1171            }
1172        } else {
1173            let end = find_named_object_end(block, i).unwrap_or(block.len());
1174            objects.push((i, end));
1175            i = end;
1176        }
1177        let rest = block.get(i..).unwrap_or("").trim_start();
1178        i += block[i..].len() - rest.len();
1179        if rest.starts_with(',') {
1180            i += 1;
1181        } else {
1182            break;
1183        }
1184    }
1185    objects
1186}
1187
1188fn remove_matching_predicate_object(
1189    block: &str,
1190    predicate: &str,
1191    object_value: &str,
1192) -> Option<String> {
1193    let obj_trim = object_value.trim();
1194    let norm_obj = normalize_ws(obj_trim);
1195    let mut search_from = 0;
1196    while let Some(pred_pos) = find_predicate_token(block, search_from, predicate) {
1197        for (obj_start, obj_end) in objects_in_predicate_value(block, pred_pos, predicate) {
1198            let candidate = normalize_ws(block[obj_start..obj_end].trim());
1199            if candidate == norm_obj {
1200                let (remove_start, remove_end) =
1201                    extend_removal_span(block, pred_pos, predicate, obj_start, obj_end);
1202                let mut out = String::new();
1203                out.push_str(&block[..remove_start]);
1204                out.push_str(&block[remove_end..]);
1205                return Some(cleanup_block_separators(&out));
1206            }
1207        }
1208        search_from = pred_pos + 1;
1209    }
1210    None
1211}
1212
1213fn text_contains_entity(
1214    text: &str,
1215    entity_iri: &str,
1216    namespaces: &BTreeMap<String, String>,
1217) -> bool {
1218    let namespaces = crate::span::namespaces_for_text(text, namespaces);
1219    let short = short_name_from_iri(entity_iri);
1220    let mut needles = vec![entity_iri.to_string(), format!("<{entity_iri}>")];
1221    for (prefix, ns) in &namespaces {
1222        if entity_iri.starts_with(ns) {
1223            needles.push(format!("{prefix}:{short}"));
1224        }
1225    }
1226    text.lines().any(|line| {
1227        let trimmed = line.trim_start();
1228        needles.iter().any(|needle| line_starts_with_subject(trimmed, needle))
1229    })
1230}
1231
1232fn line_starts_with_subject(trimmed: &str, subject: &str) -> bool {
1233    trimmed == subject
1234        || trimmed.starts_with(&format!("{subject} "))
1235        || trimmed.starts_with(&format!("{subject}\t"))
1236        || trimmed.starts_with(&format!("{subject};"))
1237        || trimmed.starts_with(&format!("{subject}."))
1238}
1239
1240/// Reject IRIs that would break Turtle `<...>` terms or inject syntax.
1241pub(crate) fn is_safe_iri(iri: &str) -> bool {
1242    if iri.is_empty() {
1243        return false;
1244    }
1245    !iri.chars().any(|c| {
1246        c.is_control()
1247            || c.is_whitespace()
1248            || matches!(c, '<' | '>' | '"' | '{' | '}' | '|' | '^' | '`' | '\\')
1249    })
1250}
1251
1252/// True when `local` is a valid Turtle PN_LOCAL (simplified).
1253fn is_valid_pn_local(local: &str) -> bool {
1254    if local.is_empty() {
1255        return false;
1256    }
1257    local.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '~'))
1258        && !local.starts_with('.')
1259        && !local.ends_with('.')
1260}
1261
1262fn iri_to_turtle_term(iri: &str, namespaces: &BTreeMap<String, String>) -> Result<String> {
1263    iri_to_turtle_term_impl(iri, namespaces)
1264}
1265
1266pub(crate) fn iri_to_turtle_term_impl(
1267    iri: &str,
1268    namespaces: &BTreeMap<String, String>,
1269) -> Result<String> {
1270    if !is_safe_iri(iri) {
1271        return Err(OwlError::PatchInvalid(format!(
1272            "IRI contains characters that cannot be safely written to Turtle: {iri:?}"
1273        )));
1274    }
1275    if iri == "http://www.w3.org/2002/07/owl#Thing" {
1276        return Ok("owl:Thing".to_string());
1277    }
1278
1279    if let Some((prefix, ns)) = best_namespace_match(iri, namespaces) {
1280        let local = &iri[ns.len()..];
1281        if is_valid_pn_local(local) {
1282            return Ok(format!("{prefix}:{local}"));
1283        }
1284    }
1285    Ok(format!("<{iri}>"))
1286}
1287
1288pub(crate) fn best_namespace_match<'a>(
1289    iri: &str,
1290    namespaces: &'a BTreeMap<String, String>,
1291) -> Option<(&'a str, &'a str)> {
1292    let mut best: Option<(&str, &str, usize)> = None;
1293    for (prefix, ns) in namespaces {
1294        if prefix.is_empty() || !iri.starts_with(ns.as_str()) {
1295            continue;
1296        }
1297        let len = ns.len();
1298        if best.as_ref().is_none_or(|(_, _, best_len)| len > *best_len) {
1299            best = Some((prefix.as_str(), ns.as_str(), len));
1300        }
1301    }
1302    best.map(|(prefix, ns, _)| (prefix, ns))
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307    use super::*;
1308
1309    fn ex_ns() -> BTreeMap<String, String> {
1310        BTreeMap::from([
1311            ("ex".to_string(), "http://example.org/people#".to_string()),
1312            ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
1313            ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
1314        ])
1315    }
1316
1317    #[test]
1318    fn add_and_remove_import() {
1319        let ttl = r#"@prefix owl: <http://www.w3.org/2002/07/owl#> .
1320@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
1321
1322<http://example.org/people> a owl:Ontology ;
1323    rdfs:label "People" .
1324"#;
1325        let ns = ex_ns();
1326        let add = apply_patches_to_text(
1327            ttl,
1328            &[PatchOp::AddImport {
1329                ontology_iri: "http://example.org/people".to_string(),
1330                import_iri: "http://example.org/org".to_string(),
1331            }],
1332            true,
1333            &ns,
1334        )
1335        .expect("add import");
1336        let with_import = add.preview_text.expect("preview");
1337        assert!(with_import.contains("owl:imports <http://example.org/org>"));
1338
1339        let remove = apply_patches_to_text(
1340            &with_import,
1341            &[PatchOp::RemoveImport {
1342                ontology_iri: "http://example.org/people".to_string(),
1343                import_iri: "http://example.org/org".to_string(),
1344            }],
1345            true,
1346            &ns,
1347        )
1348        .expect("remove import");
1349        let cleaned = remove.preview_text.expect("preview");
1350        assert!(!cleaned.contains("owl:imports"));
1351    }
1352
1353    #[test]
1354    fn add_label_to_existing_class() {
1355        let ttl = include_str!("../../../fixtures/example.ttl");
1356        let patches = vec![PatchOp::AddLabel {
1357            entity_iri: "http://example.org/people#Person".to_string(),
1358            value: "Human".to_string(),
1359        }];
1360        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1361        let preview = result.preview_text.expect("preview");
1362        assert_eq!(
1363            preview.matches("rdfs:label \"Human\"").count(),
1364            1,
1365            "must insert label exactly once"
1366        );
1367    }
1368
1369    #[test]
1370    fn add_label_not_blocked_by_label_text_in_long_comment() {
1371        let ttl = r#"@prefix ex: <http://example.org/ex#> .
1372@prefix owl: <http://www.w3.org/2002/07/owl#> .
1373@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
1374
1375ex:Foo a owl:Class ;
1376    rdfs:comment """Documentation mentions rdfs:label \"Bar\" syntax.""" .
1377"#;
1378        let ns = BTreeMap::from([
1379            ("ex".to_string(), "http://example.org/ex#".to_string()),
1380            ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
1381            ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
1382        ]);
1383        let patches = vec![PatchOp::AddLabel {
1384            entity_iri: "http://example.org/ex#Foo".to_string(),
1385            value: "Bar".to_string(),
1386        }];
1387        let result = apply_patches_to_text(ttl, &patches, true, &ns).expect("patch");
1388        let preview = result.preview_text.expect("preview");
1389        assert!(preview.contains("rdfs:label \"Bar\""));
1390    }
1391
1392    #[test]
1393    fn add_label_duplicate_returns_error() {
1394        let ttl = r#"@prefix ex: <http://example.org/ex#> .
1395@prefix owl: <http://www.w3.org/2002/07/owl#> .
1396@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
1397
1398ex:Foo a owl:Class ;
1399    rdfs:label "Bar" .
1400"#;
1401        let ns = BTreeMap::from([
1402            ("ex".to_string(), "http://example.org/ex#".to_string()),
1403            ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
1404            ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
1405        ]);
1406        let patches = vec![PatchOp::AddLabel {
1407            entity_iri: "http://example.org/ex#Foo".to_string(),
1408            value: "Bar".to_string(),
1409        }];
1410        let result = apply_patches_to_text(ttl, &patches, true, &ns).expect("patch");
1411        assert!(!result.applied);
1412        assert!(result.diagnostics.iter().any(|d| d.message.contains("duplicate")));
1413    }
1414
1415    #[test]
1416    fn remove_subclass_does_not_leave_orphaned_predicate() {
1417        let ttl = include_str!("../../../fixtures/example.ttl");
1418        let patches = vec![PatchOp::RemoveSubClassOf {
1419            entity_iri: "http://example.org/people#Person".to_string(),
1420            parent_iri: "http://example.org/people#Thing".to_string(),
1421        }];
1422        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1423        let preview = result.preview_text.expect("preview");
1424        assert!(!preview.contains("rdfs:subClassOf."));
1425        assert!(!preview.contains("rdfs:subClassOf ;"));
1426        assert!(!preview.contains("ex:Person rdfs:subClassOf"));
1427    }
1428
1429    #[test]
1430    fn add_subclass_of_no_duplicate_when_trailing_triple_exists() {
1431        let ttl = include_str!("../../../fixtures/example.ttl");
1432        let patches = vec![PatchOp::AddSubClassOf {
1433            entity_iri: "http://example.org/people#Person".to_string(),
1434            parent_iri: "http://example.org/people#Thing".to_string(),
1435        }];
1436        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1437        assert!(
1438            result.preview_text.is_none(),
1439            "must not duplicate subclass axiom already present as trailing triple"
1440        );
1441    }
1442
1443    #[test]
1444    fn remove_import_from_trailing_triple() {
1445        let ttl = r#"@prefix owl: <http://www.w3.org/2002/07/owl#> .
1446
1447<http://example.org/people> a owl:Ontology .
1448<http://example.org/people> owl:imports <http://example.org/other> .
1449"#;
1450        let ns = ex_ns();
1451        let result = apply_patches_to_text(
1452            ttl,
1453            &[PatchOp::RemoveImport {
1454                ontology_iri: "http://example.org/people".to_string(),
1455                import_iri: "http://example.org/other".to_string(),
1456            }],
1457            true,
1458            &ns,
1459        )
1460        .expect("remove trailing import");
1461        let preview = result.preview_text.expect("preview");
1462        assert!(!preview.contains("owl:imports"));
1463    }
1464
1465    #[test]
1466    fn set_deprecated_false_removes_trailing_flag() {
1467        let ttl = r#"@prefix ex: <http://example.org/people#> .
1468@prefix owl: <http://www.w3.org/2002/07/owl#> .
1469
1470ex:Person a owl:Class .
1471ex:Person owl:deprecated true .
1472"#;
1473        let result = apply_patches_to_text(
1474            ttl,
1475            &[PatchOp::SetDeprecated {
1476                entity_iri: "http://example.org/people#Person".to_string(),
1477                value: false,
1478            }],
1479            true,
1480            &ex_ns(),
1481        )
1482        .expect("clear deprecated");
1483        let preview = result.preview_text.expect("preview");
1484        assert!(!preview.contains("owl:deprecated"));
1485    }
1486
1487    #[test]
1488    fn remove_comment_with_period_in_literal() {
1489        let ttl = r#"@prefix ex: <http://example.org/people#> .
1490@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
1491@prefix owl: <http://www.w3.org/2002/07/owl#> .
1492
1493ex:Person a owl:Class ;
1494    rdfs:comment "A human being." .
1495"#;
1496        let patches = vec![PatchOp::RemoveComment {
1497            entity_iri: "http://example.org/people#Person".to_string(),
1498            value: "A human being.".to_string(),
1499        }];
1500        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1501        let preview = result.preview_text.expect("preview");
1502        assert!(!preview.contains("A human being."));
1503        assert!(!preview.contains("rdfs:comment"));
1504        assert!(preview.contains("ex:Person a owl:Class"));
1505    }
1506
1507    #[test]
1508    fn remove_label_ignores_predicate_name_inside_comment() {
1509        let ttl = r#"@prefix ex: <http://example.org/people#> .
1510@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
1511@prefix owl: <http://www.w3.org/2002/07/owl#> .
1512
1513ex:Person a owl:Class ;
1514    rdfs:comment "see rdfs:label usage" ;
1515    rdfs:label "Name" .
1516"#;
1517        let patches = vec![PatchOp::RemoveLabel {
1518            entity_iri: "http://example.org/people#Person".to_string(),
1519            value: "Name".to_string(),
1520        }];
1521        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1522        let preview = result.preview_text.expect("preview");
1523        assert!(preview.contains("see rdfs:label usage"));
1524        assert!(!preview.contains("rdfs:label \"Name\""));
1525    }
1526
1527    #[test]
1528    fn create_new_class() {
1529        let ttl = include_str!("../../../fixtures/example.ttl");
1530        let patches = vec![PatchOp::CreateEntity {
1531            entity_iri: "http://example.org/people#Employee".to_string(),
1532            kind: PatchEntityKind::Class,
1533        }];
1534        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1535        assert!(result.preview_text.unwrap().contains("ex:Employee"));
1536    }
1537
1538    #[test]
1539    fn batch_failure_leaves_source_unchanged() {
1540        let ttl = include_str!("../../../fixtures/example.ttl");
1541        let patches = vec![
1542            PatchOp::AddLabel {
1543                entity_iri: "http://example.org/people#Person".to_string(),
1544                value: "Human".to_string(),
1545            },
1546            PatchOp::AddLabel {
1547                entity_iri: "http://example.org/people#NoSuch".to_string(),
1548                value: "X".to_string(),
1549            },
1550        ];
1551        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1552        assert!(!result.diagnostics.is_empty());
1553        assert_eq!(result.preview_text.as_deref(), Some(ttl));
1554        assert!(!result.applied);
1555    }
1556
1557    fn clinic_ns() -> BTreeMap<String, String> {
1558        BTreeMap::from([
1559            ("ex".to_string(), "http://example.org/clinic#".to_string()),
1560            ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
1561            ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
1562        ])
1563    }
1564
1565    #[test]
1566    fn remove_complex_subclass_keeps_named_parent() {
1567        let ttl = include_str!("../../../fixtures/complex-classes.ttl");
1568        let patches = vec![PatchOp::RemoveComplexSubClassOf {
1569            entity_iri: "http://example.org/clinic#Patient".to_string(),
1570            manchester: "ex:hasRecord some ex:MedicalRecord".to_string(),
1571        }];
1572        let result = apply_patches_to_text(ttl, &patches, true, &clinic_ns()).expect("patch");
1573        let preview = result.preview_text.expect("preview");
1574        assert!(!preview.contains("owl:someValuesFrom"));
1575        assert!(preview.contains("rdfs:subClassOf ex:ClinicPerson"));
1576    }
1577
1578    #[test]
1579    fn remove_subclass_from_trailing_triple() {
1580        let ttl = include_str!("../../../fixtures/example.ttl");
1581        let patches = vec![PatchOp::RemoveSubClassOf {
1582            entity_iri: "http://example.org/people#Person".to_string(),
1583            parent_iri: "http://example.org/people#Thing".to_string(),
1584        }];
1585        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1586        let preview = result.preview_text.expect("preview");
1587        assert!(!preview.contains("ex:Person rdfs:subClassOf ex:Thing"));
1588        assert!(preview.contains("ex:Person a owl:Class"));
1589    }
1590
1591    #[test]
1592    fn delete_entity_removes_all_statements() {
1593        let ttl = include_str!("../../../fixtures/example.ttl");
1594        let patches = vec![PatchOp::DeleteEntity {
1595            entity_iri: "http://example.org/people#Person".to_string(),
1596        }];
1597        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1598        let preview = result.preview_text.expect("preview");
1599        assert!(!preview.contains("ex:Person a owl:Class"));
1600        assert!(!preview.contains("ex:Person rdfs:subClassOf"));
1601    }
1602
1603    #[test]
1604    fn crlf_line_offsets_match_byte_positions() {
1605        let ttl = "ex:Foo a owl:Class ;\r\n    rdfs:label \"Bar\" .\r\n";
1606        let ns = BTreeMap::from([
1607            ("ex".into(), "http://example.org/".into()),
1608            ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
1609        ]);
1610        let range = entity_primary_block_range(ttl, "http://example.org/Foo", &ns).expect("range");
1611        let block = &ttl[range.start as usize..range.end as usize];
1612        assert!(block.contains("rdfs:label"));
1613        assert!(block.trim_end().ends_with('.'));
1614    }
1615
1616    #[test]
1617    fn cleanup_preserves_literal_double_spaces() {
1618        let block = "ex:Foo rdfs:label \"a  b\" .";
1619        let cleaned = cleanup_block_separators(block);
1620        assert!(cleaned.contains("\"a  b\""));
1621    }
1622
1623    #[test]
1624    fn iri_with_angle_bracket_is_rejected_not_injected() {
1625        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";
1626        let evil =
1627            "http://example.org/people#X> . ex:Pwned a owl:Class . <http://example.org/people#Y";
1628        let patches = vec![PatchOp::CreateEntity {
1629            entity_iri: evil.to_string(),
1630            kind: PatchEntityKind::Class,
1631        }];
1632        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1633        assert!(!result.applied);
1634        assert!(!result.diagnostics.is_empty());
1635        assert_eq!(result.preview_text.as_deref(), Some(ttl));
1636        assert!(!ttl.contains("ex:Pwned"));
1637    }
1638
1639    #[test]
1640    fn iri_with_newline_is_rejected() {
1641        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";
1642        let evil = "http://example.org/people#X\n. ex:Injected a owl:Class .\n#";
1643        let patches = vec![PatchOp::CreateEntity {
1644            entity_iri: evil.to_string(),
1645            kind: PatchEntityKind::Class,
1646        }];
1647        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
1648        assert!(!result.applied);
1649        assert_eq!(result.preview_text.as_deref(), Some(ttl));
1650    }
1651
1652    #[test]
1653    fn longest_namespace_prefix_wins() {
1654        let ns = BTreeMap::from([
1655            ("ex".to_string(), "http://example.org/".to_string()),
1656            ("exfoo".to_string(), "http://example.org/foo/".to_string()),
1657        ]);
1658        let term = iri_to_turtle_term("http://example.org/foo/Bar", &ns).expect("term");
1659        assert_eq!(term, "exfoo:Bar");
1660    }
1661
1662    #[test]
1663    fn slash_in_local_name_uses_angle_brackets() {
1664        let ns = BTreeMap::from([("ex".to_string(), "http://example.org/".to_string())]);
1665        let term = iri_to_turtle_term("http://example.org/foo/Bar", &ns).expect("term");
1666        assert_eq!(term, "<http://example.org/foo/Bar>");
1667    }
1668
1669    #[test]
1670    fn add_disjoint_class_is_idempotent_when_axiom_exists() {
1671        let ttl = include_str!("../../../fixtures/disjoint-classes.ttl");
1672        let ns = BTreeMap::from([
1673            ("ex".to_string(), "http://example.org/org#".to_string()),
1674            ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
1675            ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
1676        ]);
1677        let patches = vec![PatchOp::AddDisjointClass {
1678            entity_iri: "http://example.org/org#Cat".to_string(),
1679            other_iri: "http://example.org/org#Dog".to_string(),
1680        }];
1681        let before = ttl.matches("owl:disjointWith").count();
1682        let result = apply_patches_to_text(ttl, &patches, true, &ns).expect("patch");
1683        let preview = result.preview_text.as_deref().unwrap_or(ttl);
1684        assert_eq!(before, preview.matches("owl:disjointWith").count());
1685    }
1686
1687    fn org_ns() -> BTreeMap<String, String> {
1688        BTreeMap::from([
1689            ("ex".to_string(), "http://example.org/org#".to_string()),
1690            ("owl".to_string(), "http://www.w3.org/2002/07/owl#".to_string()),
1691            ("rdfs".to_string(), "http://www.w3.org/2000/01/rdf-schema#".to_string()),
1692        ])
1693    }
1694
1695    #[test]
1696    fn add_and_remove_domain() {
1697        let ttl = include_str!("../../../fixtures/disjoint-classes.ttl");
1698        let ns = org_ns();
1699        let add = apply_patches_to_text(
1700            ttl,
1701            &[PatchOp::AddDomain {
1702                entity_iri: "http://example.org/org#chases".to_string(),
1703                class_iri: "http://example.org/org#Cat".to_string(),
1704            }],
1705            true,
1706            &ns,
1707        )
1708        .expect("add domain");
1709        let with_domain = add.preview_text.expect("preview");
1710        assert!(with_domain.contains("rdfs:domain"));
1711
1712        let remove = apply_patches_to_text(
1713            &with_domain,
1714            &[PatchOp::RemoveDomain {
1715                entity_iri: "http://example.org/org#chases".to_string(),
1716                class_iri: "http://example.org/org#Dog".to_string(),
1717            }],
1718            true,
1719            &ns,
1720        )
1721        .expect("remove domain");
1722        let preview = remove.preview_text.expect("preview");
1723        assert!(!preview.contains("rdfs:domain ex:Dog"));
1724        assert!(preview.contains("rdfs:domain ex:Cat"));
1725    }
1726
1727    #[test]
1728    fn add_property_chain() {
1729        let ttl = include_str!("../../../fixtures/disjoint-classes.ttl");
1730        let ns = org_ns();
1731        let result = apply_patches_to_text(
1732            ttl,
1733            &[PatchOp::AddPropertyChain {
1734                entity_iri: "http://example.org/org#chases".to_string(),
1735                properties: vec![
1736                    "http://example.org/org#chases".to_string(),
1737                    "http://example.org/org#composed".to_string(),
1738                ],
1739            }],
1740            true,
1741            &ns,
1742        )
1743        .expect("add chain");
1744        let preview = result.preview_text.expect("preview");
1745        assert!(preview.contains("owl:propertyChainAxiom"));
1746    }
1747
1748    #[test]
1749    fn add_property_chain_rejects_class_iris() {
1750        let ttl = include_str!("../../../fixtures/disjoint-classes.ttl");
1751        let ns = org_ns();
1752        let result = apply_patches_to_text(
1753            ttl,
1754            &[PatchOp::AddPropertyChain {
1755                entity_iri: "http://example.org/org#chases".to_string(),
1756                properties: vec![
1757                    "http://example.org/org#Cat".to_string(),
1758                    "http://example.org/org#Dog".to_string(),
1759                ],
1760            }],
1761            true,
1762            &ns,
1763        )
1764        .expect("patch result");
1765        assert!(!result.diagnostics.is_empty());
1766        assert!(result.diagnostics[0].message.contains("owl:Class"));
1767    }
1768
1769    #[test]
1770    fn add_class_assertion_to_individual() {
1771        let ttl = r#"@prefix ex: <http://example.org/org#> .
1772@prefix owl: <http://www.w3.org/2002/07/owl#> .
1773
1774ex:Alice a owl:NamedIndividual .
1775ex:Person a owl:Class .
1776"#;
1777        let ns = org_ns();
1778        let result = apply_patches_to_text(
1779            ttl,
1780            &[PatchOp::AddClassAssertion {
1781                entity_iri: "http://example.org/org#Alice".to_string(),
1782                class_iri: "http://example.org/org#Person".to_string(),
1783            }],
1784            true,
1785            &ns,
1786        )
1787        .expect("add class assertion");
1788        let preview = result.preview_text.expect("preview");
1789        assert!(preview.contains("ex:Person"));
1790    }
1791
1792    #[test]
1793    fn add_generic_annotation() {
1794        let ttl = r#"@prefix ex: <http://example.org/org#> .
1795@prefix owl: <http://www.w3.org/2002/07/owl#> .
1796@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
1797
1798ex:Cat a owl:Class .
1799"#;
1800        let ns = org_ns();
1801        let mut ns = ns;
1802        ns.insert("skos".to_string(), "http://www.w3.org/2004/02/skos/core#".to_string());
1803        let result = apply_patches_to_text(
1804            ttl,
1805            &[PatchOp::AddAnnotation {
1806                entity_iri: "http://example.org/org#Cat".to_string(),
1807                predicate: "skos:definition".to_string(),
1808                value: "A feline animal".to_string(),
1809            }],
1810            true,
1811            &ns,
1812        )
1813        .expect("add annotation");
1814        let preview = result.preview_text.expect("preview");
1815        assert!(preview.contains("skos:definition \"A feline animal\""));
1816    }
1817
1818    #[test]
1819    fn set_functional_property() {
1820        let ttl = include_str!("../../../fixtures/disjoint-classes.ttl");
1821        let ns = org_ns();
1822        let result = apply_patches_to_text(
1823            ttl,
1824            &[PatchOp::SetFunctional {
1825                entity_iri: "http://example.org/org#chases".to_string(),
1826                value: true,
1827            }],
1828            true,
1829            &ns,
1830        )
1831        .expect("set functional");
1832        let preview = result.preview_text.expect("preview");
1833        assert!(preview.contains("owl:FunctionalProperty"));
1834    }
1835}