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#[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
88pub 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
133fn 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 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
162pub 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 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 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
964fn 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
1024fn 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
1240pub(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
1252fn 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}