1use crate::error::{RefactorError, Result};
2use crate::model::{FileChange, Hunk, RefactorPlan};
3use crate::source::read_source_text;
4use crate::text::{normalize_namespace_base, remap_iri, replace_iri_in_text};
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6use std::path::{Path, PathBuf};
7use strixonomy_catalog::OntologyCatalog;
8use strixonomy_core::{validate_workspace_scope_any, EntityKind, OntologyFormat, ParseStatus};
9
10pub fn preview_rename_iri(
11 catalog: &OntologyCatalog,
12 from_iri: &str,
13 to_iri: &str,
14 document_overrides: &HashMap<PathBuf, String>,
15) -> Result<RefactorPlan> {
16 if from_iri == to_iri {
17 return Err(RefactorError::Invalid("from and to IRI must differ".to_string()));
18 }
19 if catalog.find_entity(from_iri).is_none()
20 && find_usages_in_catalog(catalog, from_iri, document_overrides).is_empty()
21 {
22 return Err(RefactorError::EntityNotFound(from_iri.to_string()));
23 }
24
25 let mut file_changes: BTreeMap<PathBuf, FileChange> = BTreeMap::new();
26 let mut warnings = Vec::new();
27 let from_obo = catalog.find_entity(from_iri).and_then(|e| e.obo_id.clone());
28 let to_obo = catalog.find_entity(to_iri).and_then(|e| e.obo_id.clone()).or_else(|| {
29 short_obo_id_from_iri(to_iri).or_else(|| {
31 from_obo.as_ref().map(|_| {
32 short_obo_id_from_iri(to_iri).unwrap_or_else(|| {
33 to_iri.rsplit(['#', '/']).next().unwrap_or(to_iri).to_string()
34 })
35 })
36 })
37 });
38
39 for doc in &catalog.data().documents {
40 if doc.parse_status != ParseStatus::Ok {
41 if text_contains_iri(doc, from_iri, document_overrides) {
42 warnings.push(format!("skipping errored file: {}", doc.path.display()));
43 }
44 continue;
45 }
46 let original = read_source_text(&doc.path, document_overrides)?;
47 let preview = match doc.format {
48 OntologyFormat::Turtle => {
49 if !original.contains(from_iri)
50 && !contains_prefixed_ref(&original, from_iri, &doc.namespaces)
51 {
52 continue;
53 }
54 let (mut preview_text, raw_hunks) =
55 replace_iri_in_text(&original, from_iri, to_iri, &doc.namespaces);
56 let (swrl_text, swrl_hits) =
57 strixonomy_swrl::rewrite_swrl_iris_in_turtle(&preview_text, from_iri, to_iri);
58 if swrl_hits > 0 {
59 preview_text = swrl_text;
60 warnings.push(format!(
61 "rewrote {swrl_hits} SWRL rule(s) in {}",
62 doc.path.display()
63 ));
64 }
65 if preview_text == original {
66 continue;
67 }
68 let hunks: Vec<Hunk> = raw_hunks
69 .into_iter()
70 .map(|(start, end, old_text, new_text)| Hunk {
71 start_byte: start as u64,
72 end_byte: end as u64,
73 old_text,
74 new_text,
75 })
76 .collect();
77 file_changes.insert(
78 doc.path.clone(),
79 FileChange {
80 path: doc.path.clone(),
81 preview_text,
82 original_text: original,
83 hunks,
84 },
85 );
86 continue;
87 }
88 OntologyFormat::Owl | OntologyFormat::RdfXml => {
89 if !original.contains(from_iri) {
90 continue;
91 }
92 match strixonomy_owl::remap_entity_iri_in_xml_text(
93 &original,
94 "rdfxml",
95 from_iri,
96 to_iri,
97 &doc.namespaces,
98 ) {
99 Ok(text) => text,
100 Err(e) => {
101 warnings.push(format!("skipping RDF/XML {}: {e}", doc.path.display()));
102 continue;
103 }
104 }
105 }
106 OntologyFormat::OwlXml => {
107 if !original.contains(from_iri) {
108 continue;
109 }
110 match strixonomy_owl::remap_entity_iri_in_xml_text(
111 &original,
112 "owlxml",
113 from_iri,
114 to_iri,
115 &doc.namespaces,
116 ) {
117 Ok(text) => text,
118 Err(e) => {
119 warnings.push(format!("skipping OWL/XML {}: {e}", doc.path.display()));
120 continue;
121 }
122 }
123 }
124 OntologyFormat::Obo => {
125 let from_id =
126 from_obo.clone().or_else(|| short_obo_id_from_iri(from_iri)).unwrap_or_else(
127 || from_iri.rsplit(['#', '/']).next().unwrap_or(from_iri).to_string(),
128 );
129 let to_id =
130 to_obo.clone().or_else(|| short_obo_id_from_iri(to_iri)).unwrap_or_else(|| {
131 to_iri.rsplit(['#', '/']).next().unwrap_or(to_iri).to_string()
132 });
133 if !original.contains(&from_id) {
134 continue;
135 }
136 match strixonomy_obo::remap_obo_id_in_text(&original, &from_id, &to_id) {
137 Ok(text) => text,
138 Err(e) => {
139 warnings.push(format!("skipping OBO {}: {e}", doc.path.display()));
140 continue;
141 }
142 }
143 }
144 other => {
145 if text_contains_iri(doc, from_iri, document_overrides) {
146 warnings.push(format!(
147 "skipping unsupported format {} in {}",
148 other.as_str(),
149 doc.path.display()
150 ));
151 }
152 continue;
153 }
154 };
155
156 if preview == original {
157 continue;
158 }
159 file_changes
160 .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview));
161 }
162
163 if file_changes.is_empty() {
164 warnings.push(format!("no files changed for IRI {from_iri}"));
165 }
166
167 Ok(RefactorPlan {
168 changes: file_changes.into_values().collect(),
169 warnings,
170 ..Default::default()
171 }
172 .with_metrics([from_iri, to_iri]))
173}
174
175fn short_obo_id_from_iri(iri: &str) -> Option<String> {
176 let local = iri.rsplit(['#', '/']).next()?;
178 if local.contains('_') && !local.contains(':') {
179 let (ns, id) = local.split_once('_')?;
180 return Some(format!("{ns}:{id}"));
181 }
182 if local.contains(':') {
183 return Some(local.to_string());
184 }
185 None
186}
187
188pub fn preview_merge_entities(
189 catalog: &OntologyCatalog,
190 keep_iri: &str,
191 merge_iri: &str,
192 document_overrides: &HashMap<PathBuf, String>,
193) -> Result<RefactorPlan> {
194 if keep_iri == merge_iri {
195 return Err(RefactorError::Invalid("keep and merge IRI must differ".to_string()));
196 }
197
198 let mut warnings = Vec::new();
199 if catalog.find_entity(keep_iri).is_none() {
200 warnings.push(format!("keep entity not found: {keep_iri}"));
201 }
202 if catalog.find_entity(merge_iri).is_none() {
203 warnings.push(format!("merge entity not found: {merge_iri}"));
204 }
205
206 let mut changes = BTreeMap::new();
207 for doc in &catalog.data().documents {
208 if doc.parse_status != ParseStatus::Ok {
209 if text_contains_iri(doc, merge_iri, document_overrides) {
210 warnings.push(format!("skipping errored file: {}", doc.path.display()));
211 }
212 continue;
213 }
214
215 let original = read_source_text(&doc.path, document_overrides)?;
216 let preview_text = match doc.format {
217 OntologyFormat::Turtle => {
218 if !original.contains(merge_iri)
219 && !contains_prefixed_ref(&original, merge_iri, &doc.namespaces)
220 {
221 continue;
222 }
223 let namespaces = strixonomy_owl::namespaces_for_text(&original, &doc.namespaces);
224 let short = strixonomy_owl::short_name_from_iri(merge_iri);
225 let mut declaration_ranges = strixonomy_owl::all_entity_statement_ranges(
226 &original,
227 merge_iri,
228 &short,
229 &namespaces,
230 );
231 declaration_ranges.sort_by_key(|range| std::cmp::Reverse(range.start));
232
233 let mut without_merge_declaration = original.clone();
234 for range in declaration_ranges {
235 without_merge_declaration
236 .replace_range(range.start as usize..range.end as usize, "");
237 }
238 let (mut preview_text, _) = replace_iri_in_text(
239 &without_merge_declaration,
240 merge_iri,
241 keep_iri,
242 &doc.namespaces,
243 );
244 let (swrl_text, swrl_hits) = strixonomy_swrl::rewrite_swrl_iris_in_turtle(
245 &preview_text,
246 merge_iri,
247 keep_iri,
248 );
249 if swrl_hits > 0 {
250 preview_text = swrl_text;
251 warnings.push(format!(
252 "rewrote {swrl_hits} SWRL rule(s) in {}",
253 doc.path.display()
254 ));
255 }
256 preview_text
257 }
258 OntologyFormat::Owl | OntologyFormat::RdfXml | OntologyFormat::OwlXml => {
259 if !original.contains(merge_iri) {
260 continue;
261 }
262 let fmt = if doc.format == OntologyFormat::OwlXml { "owlxml" } else { "rdfxml" };
263 match strixonomy_owl::merge_entity_iri_in_xml_text(
265 &original,
266 fmt,
267 keep_iri,
268 merge_iri,
269 &doc.namespaces,
270 ) {
271 Ok(text) => text,
272 Err(e) => {
273 warnings.push(format!("skipping {} {}: {e}", fmt, doc.path.display()));
274 continue;
275 }
276 }
277 }
278 OntologyFormat::Obo => {
279 let from_id = catalog
280 .find_entity(merge_iri)
281 .and_then(|e| e.obo_id.clone())
282 .or_else(|| short_obo_id_from_iri(merge_iri))
283 .unwrap_or_else(|| {
284 merge_iri.rsplit(['#', '/']).next().unwrap_or(merge_iri).to_string()
285 });
286 let to_id = catalog
287 .find_entity(keep_iri)
288 .and_then(|e| e.obo_id.clone())
289 .or_else(|| short_obo_id_from_iri(keep_iri))
290 .unwrap_or_else(|| {
291 keep_iri.rsplit(['#', '/']).next().unwrap_or(keep_iri).to_string()
292 });
293 if !original.contains(&from_id) {
294 continue;
295 }
296 match strixonomy_obo::merge_obo_id_in_text(&original, &from_id, &to_id) {
298 Ok(text) => text,
299 Err(e) => {
300 warnings.push(format!("skipping OBO {}: {e}", doc.path.display()));
301 continue;
302 }
303 }
304 }
305 other => {
306 if text_contains_iri(doc, merge_iri, document_overrides) {
307 warnings.push(format!(
308 "skipping unsupported format {} in {}",
309 other.as_str(),
310 doc.path.display()
311 ));
312 }
313 continue;
314 }
315 };
316
317 if preview_text == original {
318 continue;
319 }
320
321 changes
322 .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview_text));
323 }
324
325 if changes.is_empty() {
326 warnings.push(format!("no files changed for entity merge {merge_iri} -> {keep_iri}"));
327 }
328
329 Ok(RefactorPlan { changes: changes.into_values().collect(), warnings, ..Default::default() }
330 .with_metrics([keep_iri, merge_iri]))
331}
332
333pub fn preview_replace_entity(
334 catalog: &OntologyCatalog,
335 from_iri: &str,
336 to_iri: &str,
337 document_overrides: &HashMap<PathBuf, String>,
338) -> Result<RefactorPlan> {
339 if catalog.find_entity(to_iri).is_none() {
340 return preview_rename_iri(catalog, from_iri, to_iri, document_overrides);
341 }
342 if from_iri == to_iri {
343 return Err(RefactorError::Invalid("from and to IRI must differ".to_string()));
344 }
345 if catalog.find_entity(from_iri).is_none()
346 && find_usages_in_catalog(catalog, from_iri, document_overrides).is_empty()
347 {
348 return Err(RefactorError::EntityNotFound(from_iri.to_string()));
349 }
350
351 let mut changes = BTreeMap::new();
352 let mut warnings = Vec::new();
353 for doc in &catalog.data().documents {
354 if doc.parse_status != ParseStatus::Ok {
355 if text_contains_iri(doc, from_iri, document_overrides) {
356 warnings.push(format!("skipping errored file: {}", doc.path.display()));
357 }
358 continue;
359 }
360
361 let original = read_source_text(&doc.path, document_overrides)?;
362 let preview_text = match doc.format {
363 OntologyFormat::Turtle => {
364 if !original.contains(from_iri)
365 && !contains_prefixed_ref(&original, from_iri, &doc.namespaces)
366 {
367 continue;
368 }
369 let namespaces = strixonomy_owl::namespaces_for_text(&original, &doc.namespaces);
370 let declaration_start =
371 strixonomy_owl::entity_primary_block_range(&original, from_iri, &namespaces)
372 .map(|range| range.start as usize);
373 let mut preview_text = replace_iri_preserving_subject(
374 &original,
375 from_iri,
376 to_iri,
377 &doc.namespaces,
378 declaration_start,
379 );
380 let (swrl_text, swrl_hits) =
381 strixonomy_swrl::rewrite_swrl_iris_in_turtle(&preview_text, from_iri, to_iri);
382 if swrl_hits > 0 {
383 preview_text = swrl_text;
384 warnings.push(format!(
385 "rewrote {swrl_hits} SWRL rule(s) in {}",
386 doc.path.display()
387 ));
388 }
389 preview_text
390 }
391 OntologyFormat::Owl | OntologyFormat::RdfXml | OntologyFormat::OwlXml => {
392 if !original.contains(from_iri) {
393 continue;
394 }
395 warnings.push(format!(
397 "non-Turtle replace rewrites all IRI mentions in {}",
398 doc.path.display()
399 ));
400 let fmt = if doc.format == OntologyFormat::OwlXml { "owlxml" } else { "rdfxml" };
401 match strixonomy_owl::remap_entity_iri_in_xml_text(
402 &original,
403 fmt,
404 from_iri,
405 to_iri,
406 &doc.namespaces,
407 ) {
408 Ok(text) => text,
409 Err(e) => {
410 warnings.push(format!("skipping {} {}: {e}", fmt, doc.path.display()));
411 continue;
412 }
413 }
414 }
415 OntologyFormat::Obo => {
416 let from_id = catalog
417 .find_entity(from_iri)
418 .and_then(|e| e.obo_id.clone())
419 .or_else(|| short_obo_id_from_iri(from_iri))
420 .unwrap_or_else(|| {
421 from_iri.rsplit(['#', '/']).next().unwrap_or(from_iri).to_string()
422 });
423 let to_id = catalog
424 .find_entity(to_iri)
425 .and_then(|e| e.obo_id.clone())
426 .or_else(|| short_obo_id_from_iri(to_iri))
427 .unwrap_or_else(|| {
428 to_iri.rsplit(['#', '/']).next().unwrap_or(to_iri).to_string()
429 });
430 if !original.contains(&from_id) {
431 continue;
432 }
433 match strixonomy_obo::replace_obo_id_refs_in_text(&original, &from_id, &to_id) {
435 Ok(text) => text,
436 Err(e) => {
437 warnings.push(format!("skipping OBO {}: {e}", doc.path.display()));
438 continue;
439 }
440 }
441 }
442 other => {
443 if text_contains_iri(doc, from_iri, document_overrides) {
444 warnings.push(format!(
445 "skipping unsupported format {} in {}",
446 other.as_str(),
447 doc.path.display()
448 ));
449 }
450 continue;
451 }
452 };
453
454 if preview_text == original {
455 continue;
456 }
457
458 changes
459 .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview_text));
460 }
461
462 if changes.is_empty() {
463 warnings.push(format!("no files changed for entity replacement {from_iri} -> {to_iri}"));
464 }
465
466 Ok(RefactorPlan { changes: changes.into_values().collect(), warnings, ..Default::default() }
467 .with_metrics([from_iri, to_iri]))
468}
469
470fn replace_iri_preserving_subject(
471 text: &str,
472 from_iri: &str,
473 to_iri: &str,
474 namespaces: &BTreeMap<String, String>,
475 subject_start: Option<usize>,
476) -> String {
477 let Some(subject_start) = subject_start else {
478 return replace_iri_in_text(text, from_iri, to_iri, namespaces).0;
479 };
480 let subject_text = &text[subject_start..];
481 let subject_end = if subject_text.starts_with('<') {
482 subject_text.find('>').map(|offset| subject_start + offset + 1).unwrap_or(text.len())
483 } else {
484 subject_text
485 .find(|c: char| c.is_whitespace() || c == ';')
486 .map(|offset| subject_start + offset)
487 .unwrap_or(text.len())
488 };
489
490 let before = replace_iri_in_text(&text[..subject_start], from_iri, to_iri, namespaces).0;
491 let after = replace_iri_in_text(&text[subject_end..], from_iri, to_iri, namespaces).0;
492 format!("{before}{}{after}", &text[subject_start..subject_end])
493}
494
495fn whole_file_change(path: PathBuf, original_text: String, preview_text: String) -> FileChange {
496 FileChange {
497 path,
498 hunks: vec![Hunk {
499 start_byte: 0,
500 end_byte: original_text.len() as u64,
501 old_text: original_text.clone(),
502 new_text: preview_text.clone(),
503 }],
504 preview_text,
505 original_text,
506 }
507}
508
509pub fn preview_migrate_namespace(
510 catalog: &OntologyCatalog,
511 from_base: &str,
512 to_base: &str,
513 document_overrides: &HashMap<PathBuf, String>,
514) -> Result<RefactorPlan> {
515 let from = normalize_namespace_base(from_base);
516 let to = normalize_namespace_base(to_base);
517 if from == to {
518 return Err(RefactorError::Invalid("from and to namespace must differ".to_string()));
519 }
520
521 let all_iris: Vec<String> = catalog
522 .data()
523 .entities
524 .iter()
525 .filter(|e| remap_iri(&e.iri, &from, &to).is_some())
526 .map(|e| e.iri.clone())
527 .chain(catalog.data().axioms.iter().flat_map(|a| {
528 let mut v = Vec::new();
529 if remap_iri(&a.subject, &from, &to).is_some() {
530 v.push(a.subject.clone());
531 }
532 if remap_iri(&a.object, &from, &to).is_some() {
533 v.push(a.object.clone());
534 }
535 v
536 }))
537 .collect::<BTreeSet<_>>()
538 .into_iter()
539 .collect();
540
541 let mut changes: BTreeMap<PathBuf, FileChange> = BTreeMap::new();
542 let mut warnings = Vec::new();
543
544 for doc in &catalog.data().documents {
545 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
546 continue;
547 }
548 let original = read_source_text(&doc.path, document_overrides)?;
549 let mut preview = original.clone();
550 let mut hunks = Vec::new();
551 let mut changed = false;
552
553 for old_iri in &all_iris {
554 let new_iri = remap_iri(old_iri, &from, &to).unwrap_or_else(|| old_iri.clone());
555 if !preview.contains(old_iri)
556 && !contains_prefixed_ref(&preview, old_iri, &doc.namespaces)
557 {
558 continue;
559 }
560 let (next, raw_hunks) =
561 replace_iri_in_text(&preview, old_iri, &new_iri, &doc.namespaces);
562 if next != preview {
563 preview = next;
564 changed = true;
565 hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
566 start_byte: s as u64,
567 end_byte: e as u64,
568 old_text: o,
569 new_text: n,
570 }));
571 }
572 }
573
574 for (prefix, ns) in &doc.namespaces {
575 if normalize_namespace_base(ns) == from {
576 let terminator = if ns.ends_with('/') { '/' } else { '#' };
577 let new_ns = format!("{to}{terminator}");
578 let (next, raw_hunks) = replace_prefix_uri(&preview, prefix, ns, &new_ns);
579 if next != preview {
580 preview = next;
581 changed = true;
582 hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
583 start_byte: s as u64,
584 end_byte: e as u64,
585 old_text: o,
586 new_text: n,
587 }));
588 }
589 }
590 }
591
592 if changed {
593 changes.insert(
594 doc.path.clone(),
595 FileChange {
596 path: doc.path.clone(),
597 preview_text: preview,
598 original_text: original,
599 hunks,
600 },
601 );
602 }
603 }
604
605 if changes.is_empty() {
606 warnings.push(format!("no Turtle files changed for namespace migration {from} -> {to}"));
607 }
608
609 Ok(RefactorPlan { changes: changes.into_values().collect(), warnings, ..Default::default() }
610 .with_metrics([from, to]))
611}
612
613fn is_prefix_declaration_line(line: &str) -> bool {
614 let keyword = line.split_whitespace().next().unwrap_or("");
615 keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")
616}
617
618fn prefix_declaration_name(line: &str) -> Option<&str> {
619 let mut parts = line.split_whitespace();
620 let keyword = parts.next()?;
621 if !(keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")) {
622 return None;
623 }
624 parts.next()?.strip_suffix(':')
625}
626
627fn replace_prefix_uri(
628 text: &str,
629 prefix: &str,
630 old_uri: &str,
631 new_uri: &str,
632) -> (String, Vec<(usize, usize, String, String)>) {
633 let old_term = format!("<{old_uri}>");
634 let new_term = format!("<{new_uri}>");
635 let mut out = String::with_capacity(text.len());
636 let mut hunks = Vec::new();
637 let mut offset = 0usize;
638 for line in text.split_inclusive('\n') {
639 let updated = if prefix_declaration_name(line) == Some(prefix) && line.contains(&old_term) {
640 line.replacen(&old_term, &new_term, 1)
641 } else {
642 line.to_string()
643 };
644 if updated != line {
645 hunks.push((offset, offset + line.len(), line.to_string(), updated.clone()));
646 }
647 out.push_str(&updated);
648 offset += line.len();
649 }
650 (out, hunks)
651}
652
653fn escape_turtle_string(value: &str) -> String {
654 let mut out = String::with_capacity(value.len());
655 for ch in value.chars() {
656 match ch {
657 '\\' => out.push_str("\\\\"),
658 '"' => out.push_str("\\\""),
659 '\n' => out.push_str("\\n"),
660 '\r' => out.push_str("\\r"),
661 '\t' => out.push_str("\\t"),
662 c => out.push(c),
663 }
664 }
665 out
666}
667
668fn find_usages_in_catalog(
669 catalog: &OntologyCatalog,
670 iri: &str,
671 document_overrides: &HashMap<PathBuf, String>,
672) -> Vec<()> {
673 let u = crate::usages::find_usages_with_overrides(catalog, iri, document_overrides);
674 vec![(); u.len()]
675}
676
677fn text_contains_iri(
678 doc: &strixonomy_core::OntologyDocument,
679 iri: &str,
680 document_overrides: &HashMap<PathBuf, String>,
681) -> bool {
682 read_source_text(&doc.path, document_overrides).map(|t| t.contains(iri)).unwrap_or(false)
683}
684
685fn contains_prefixed_ref(text: &str, iri: &str, namespaces: &BTreeMap<String, String>) -> bool {
686 let short = strixonomy_owl::short_name_from_iri(iri);
687 for (prefix, ns) in namespaces {
688 if iri.starts_with(ns) && text.contains(&format!("{prefix}:{short}")) {
689 return true;
690 }
691 }
692 false
693}
694
695fn prefixed_curie(iri: &str, namespaces: &BTreeMap<String, String>) -> String {
696 let short = strixonomy_owl::short_name_from_iri(iri);
697 for (prefix, ns) in namespaces {
698 if iri.starts_with(ns) && !prefix.is_empty() {
699 return format!("{prefix}:{short}");
700 }
701 }
702 format!("<{iri}>")
703}
704
705fn owl_type_for_kind(kind: EntityKind) -> &'static str {
706 match kind {
707 EntityKind::Class => "owl:Class",
708 EntityKind::ObjectProperty => "owl:ObjectProperty",
709 EntityKind::DataProperty => "owl:DatatypeProperty",
710 EntityKind::AnnotationProperty => "owl:AnnotationProperty",
711 EntityKind::Individual => "owl:NamedIndividual",
712 EntityKind::Datatype => "rdfs:Datatype",
713 EntityKind::Ontology => "owl:Ontology",
714 EntityKind::Other => "owl:Class",
715 }
716}
717
718struct EntityRemoval {
719 path: PathBuf,
720 start: u64,
721 end: u64,
722 replacement: String,
723}
724
725pub fn preview_refactor(
728 catalog: &OntologyCatalog,
729 request: &crate::model::RefactorRequest,
730 document_overrides: &HashMap<PathBuf, String>,
731 workspace_roots: &[PathBuf],
732) -> Result<RefactorPlan> {
733 match request {
734 crate::model::RefactorRequest::RenameIri { from_iri, to_iri } => {
735 preview_rename_iri(catalog, from_iri, to_iri, document_overrides)
736 }
737 crate::model::RefactorRequest::MergeEntities { keep_iri, merge_iri } => {
738 preview_merge_entities(catalog, keep_iri, merge_iri, document_overrides)
739 }
740 crate::model::RefactorRequest::ReplaceEntity { from_iri, to_iri } => {
741 preview_replace_entity(catalog, from_iri, to_iri, document_overrides)
742 }
743 crate::model::RefactorRequest::MigrateNamespace { from_base, to_base } => {
744 preview_migrate_namespace(catalog, from_base, to_base, document_overrides)
745 }
746 crate::model::RefactorRequest::MoveEntity { entity_iri, target_file } => {
747 preview_move_entity(
748 catalog,
749 entity_iri,
750 target_file,
751 document_overrides,
752 workspace_roots,
753 )
754 }
755 crate::model::RefactorRequest::ExtractModule {
756 entity_iris,
757 output_file,
758 leave_stub,
759 locality,
760 } => preview_extract_module(
761 catalog,
762 entity_iris,
763 output_file,
764 *leave_stub,
765 *locality,
766 document_overrides,
767 workspace_roots,
768 ),
769 crate::model::RefactorRequest::MoveAxioms {
770 entity_iri,
771 target_file,
772 statement_indexes,
773 exclude_primary,
774 } => preview_move_axioms(
775 catalog,
776 entity_iri,
777 target_file,
778 statement_indexes,
779 *exclude_primary,
780 document_overrides,
781 workspace_roots,
782 ),
783 crate::model::RefactorRequest::MergeOntologies { source_paths, target_file } => {
784 crate::ontology::preview_merge_ontologies(
785 catalog,
786 source_paths,
787 target_file,
788 document_overrides,
789 workspace_roots,
790 )
791 }
792 crate::model::RefactorRequest::FlattenImports { ontology_file } => {
793 crate::ontology::preview_flatten_imports(
794 catalog,
795 ontology_file,
796 document_overrides,
797 workspace_roots,
798 )
799 }
800 crate::model::RefactorRequest::CleanupImports { ontology_file } => {
801 crate::ontology::preview_cleanup_imports(
802 catalog,
803 ontology_file,
804 document_overrides,
805 workspace_roots,
806 )
807 }
808 }
809}
810
811fn require_path_in_workspace(path: &Path, workspace_roots: &[PathBuf]) -> Result<()> {
812 validate_workspace_scope_any(path, workspace_roots).map_err(RefactorError::Invalid)?;
813 Ok(())
814}
815
816fn canonical_path(path: &Path) -> PathBuf {
817 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
818}
819
820pub fn preview_move_entity(
821 catalog: &OntologyCatalog,
822 entity_iri: &str,
823 target_file: &Path,
824 document_overrides: &HashMap<PathBuf, String>,
825 workspace_roots: &[PathBuf],
826) -> Result<RefactorPlan> {
827 require_path_in_workspace(target_file, workspace_roots)?;
829 catalog
830 .find_entity(entity_iri)
831 .ok_or_else(|| RefactorError::EntityNotFound(entity_iri.to_string()))?;
832 let source_doc = catalog
833 .entity_document(entity_iri)
834 .ok_or_else(|| RefactorError::Invalid(format!("no document for {entity_iri}")))?;
835 if source_doc.format != OntologyFormat::Turtle {
836 return Err(RefactorError::UnsupportedFormat(source_doc.format.as_str().to_string()));
837 }
838
839 let source_canon = canonical_path(&source_doc.path);
840 let target_canon = canonical_path(target_file);
841 if source_canon == target_canon {
842 return Err(RefactorError::Invalid(
843 "target file must differ from source document".to_string(),
844 ));
845 }
846
847 let source_text = read_source_text(&source_doc.path, document_overrides)?;
848 let namespaces = strixonomy_owl::namespaces_for_text(&source_text, &source_doc.namespaces);
849 let short = strixonomy_owl::short_name_from_iri(entity_iri);
850 let mut ranges =
851 strixonomy_owl::all_entity_statement_ranges(&source_text, entity_iri, &short, &namespaces);
852 if ranges.is_empty() {
853 return Err(RefactorError::Invalid(format!("entity block not found for {entity_iri}")));
854 }
855 ranges.sort_by_key(|r| r.start);
856
857 let mut block_parts = Vec::new();
858 let mut hunks = Vec::new();
859 for range in &ranges {
860 let part = source_text[range.start as usize..range.end as usize].to_string();
861 block_parts.push(part.clone());
862 hunks.push(Hunk {
863 start_byte: range.start,
864 end_byte: range.end,
865 old_text: part,
866 new_text: String::new(),
867 });
868 }
869 let block_text = block_parts.join("\n");
870 let mut source_without = source_text.clone();
871 for range in ranges.into_iter().rev() {
872 source_without.replace_range(range.start as usize..range.end as usize, "");
873 }
874
875 let mut prefix_lines = BTreeSet::new();
876 for line in source_text.lines() {
877 if is_prefix_declaration_line(line) {
878 prefix_lines.insert(line.trim().to_string());
879 }
880 }
881 let prefix_header: String = prefix_lines.iter().cloned().collect::<Vec<_>>().join("\n");
882 let block_with_prefixes = if prefix_header.is_empty() {
883 block_text.clone()
884 } else {
885 format!("{prefix_header}\n\n{block_text}")
886 };
887
888 let target_original = if target_file.exists() {
889 read_source_text(target_file, document_overrides)?
890 } else {
891 String::new()
892 };
893 let target_was_empty = target_original.is_empty();
894 let mut warnings = Vec::new();
895 let (target_preview, target_hunk_new) = if target_was_empty {
896 let mut out = block_with_prefixes.clone();
897 if !out.ends_with('\n') {
898 out.push('\n');
899 }
900 (out, block_with_prefixes)
901 } else {
902 let target_prefix_names: BTreeSet<String> = target_original
904 .lines()
905 .filter_map(prefix_declaration_name)
906 .map(str::to_string)
907 .collect();
908 let mut missing_prefixes = Vec::new();
909 for line in &prefix_lines {
910 let Some(name) = prefix_declaration_name(line) else {
911 continue;
912 };
913 if !target_prefix_names.contains(name) {
914 missing_prefixes.push(line.clone());
915 }
916 }
917 let inserted = if missing_prefixes.is_empty() {
918 String::new()
919 } else {
920 format!("{}\n\n", missing_prefixes.join("\n"))
921 };
922 if !missing_prefixes.is_empty() {
923 warnings.push(format!(
924 "added {} missing @prefix declaration(s) to {}",
925 missing_prefixes.len(),
926 target_file.display()
927 ));
928 }
929 let hunk_new = format!("{inserted}{block_text}");
930 (format!("{target_original}\n\n{hunk_new}"), hunk_new)
931 };
932
933 Ok(RefactorPlan {
934 changes: vec![
935 FileChange {
936 path: source_doc.path.clone(),
937 preview_text: source_without,
938 original_text: source_text,
939 hunks,
940 },
941 FileChange {
942 path: target_file.to_path_buf(),
943 preview_text: target_preview,
944 original_text: target_original,
945 hunks: vec![Hunk {
946 start_byte: 0,
947 end_byte: 0,
948 old_text: String::new(),
949 new_text: target_hunk_new,
950 }],
951 },
952 ],
953 warnings,
954 ..Default::default()
955 }
956 .with_metrics([entity_iri]))
957}
958
959pub fn preview_move_axioms(
961 catalog: &OntologyCatalog,
962 entity_iri: &str,
963 target_file: &Path,
964 statement_indexes: &[usize],
965 exclude_primary: bool,
966 document_overrides: &HashMap<PathBuf, String>,
967 workspace_roots: &[PathBuf],
968) -> Result<RefactorPlan> {
969 require_path_in_workspace(target_file, workspace_roots)?;
970 catalog
971 .find_entity(entity_iri)
972 .ok_or_else(|| RefactorError::EntityNotFound(entity_iri.to_string()))?;
973 let Some(source_doc) = catalog.entity_document(entity_iri) else {
974 return Err(RefactorError::EntityNotFound(entity_iri.to_string()));
975 };
976 if source_doc.format != OntologyFormat::Turtle || source_doc.parse_status != ParseStatus::Ok {
977 return Err(RefactorError::Invalid(
978 "move axioms currently supports Turtle documents only".to_string(),
979 ));
980 }
981 let source_canon = canonical_path(&source_doc.path);
982 let target_canon = canonical_path(target_file);
983 if source_canon == target_canon {
984 return Err(RefactorError::Invalid(
985 "target file must differ from the source document".to_string(),
986 ));
987 }
988
989 let source_text = read_source_text(&source_doc.path, document_overrides)?;
990 let namespaces = strixonomy_owl::namespaces_for_text(&source_text, &source_doc.namespaces);
991 let short = strixonomy_owl::short_name_from_iri(entity_iri);
992 let ranges =
993 strixonomy_owl::all_entity_statement_ranges(&source_text, entity_iri, &short, &namespaces);
994 if ranges.is_empty() {
995 return Err(RefactorError::Invalid(format!(
996 "no Turtle statements found for entity {entity_iri}"
997 )));
998 }
999
1000 let primary = strixonomy_owl::entity_primary_block_range(&source_text, entity_iri, &namespaces);
1001 let selectable: Vec<(usize, strixonomy_owl::ByteRange)> = ranges
1002 .into_iter()
1003 .enumerate()
1004 .filter(|(_, range)| {
1005 if !exclude_primary {
1006 return true;
1007 }
1008 primary.map(|p| p.start != range.start || p.end != range.end).unwrap_or(true)
1009 })
1010 .collect();
1011
1012 if selectable.is_empty() {
1013 return Err(RefactorError::Invalid(
1014 "no movable axiom statements (only primary declaration remains)".to_string(),
1015 ));
1016 }
1017
1018 let chosen: Vec<strixonomy_owl::ByteRange> = if statement_indexes.is_empty() {
1019 selectable.into_iter().map(|(_, r)| r).collect()
1020 } else {
1021 let mut out = Vec::new();
1022 for idx in statement_indexes {
1023 let Some((_, range)) = selectable.iter().find(|(i, _)| *i == *idx) else {
1024 return Err(RefactorError::Invalid(format!(
1025 "statement index {idx} out of range for entity {entity_iri}"
1026 )));
1027 };
1028 out.push(*range);
1029 }
1030 out
1031 };
1032
1033 let mut warnings = Vec::new();
1034 let mut blocks = Vec::new();
1035 let mut source_without = source_text.clone();
1036 let mut ordered = chosen;
1037 ordered.sort_by_key(|r| std::cmp::Reverse(r.start));
1038 for range in &ordered {
1039 let block = source_text[range.start as usize..range.end as usize].trim().to_string();
1040 if !block.is_empty() {
1041 blocks.push(block);
1042 }
1043 source_without.replace_range(range.start as usize..range.end as usize, "");
1044 }
1045 while source_without.contains("\n\n\n") {
1046 source_without = source_without.replace("\n\n\n", "\n\n");
1047 }
1048 blocks.reverse();
1049 let moved_text = blocks.join("\n\n");
1050 if moved_text.is_empty() {
1051 return Err(RefactorError::Invalid("selected statements produced empty move".to_string()));
1052 }
1053
1054 let target_original = if target_file.exists() {
1055 read_source_text(target_file, document_overrides)?
1056 } else {
1057 String::new()
1058 };
1059 let prefix_lines: Vec<String> = source_text
1060 .lines()
1061 .filter(|line| is_prefix_declaration_line(line))
1062 .map(str::to_string)
1063 .collect();
1064 let target_prefix_names: BTreeSet<String> =
1065 target_original.lines().filter_map(prefix_declaration_name).map(str::to_string).collect();
1066 let mut missing_prefixes = Vec::new();
1067 for line in &prefix_lines {
1068 let Some(name) = prefix_declaration_name(line) else {
1069 continue;
1070 };
1071 if !target_prefix_names.contains(name) {
1072 missing_prefixes.push(line.clone());
1073 }
1074 }
1075 let inserted = if missing_prefixes.is_empty() {
1076 String::new()
1077 } else {
1078 warnings.push(format!(
1079 "added {} missing @prefix declaration(s) to {}",
1080 missing_prefixes.len(),
1081 target_file.display()
1082 ));
1083 format!("{}\n\n", missing_prefixes.join("\n"))
1084 };
1085 let target_preview = if target_original.is_empty() {
1086 format!("{inserted}{moved_text}\n")
1087 } else {
1088 format!("{target_original}\n\n{inserted}{moved_text}\n")
1089 };
1090
1091 Ok(RefactorPlan {
1092 changes: vec![
1093 FileChange {
1094 path: source_doc.path.clone(),
1095 preview_text: source_without,
1096 original_text: source_text,
1097 hunks: vec![],
1098 },
1099 FileChange {
1100 path: target_file.to_path_buf(),
1101 preview_text: target_preview.clone(),
1102 original_text: target_original,
1103 hunks: vec![Hunk {
1104 start_byte: 0,
1105 end_byte: 0,
1106 old_text: String::new(),
1107 new_text: format!("{inserted}{moved_text}"),
1108 }],
1109 },
1110 ],
1111 warnings,
1112 ..Default::default()
1113 }
1114 .with_metrics([entity_iri]))
1115}
1116
1117pub fn preview_extract_module(
1118 catalog: &OntologyCatalog,
1119 entity_iris: &[String],
1120 output_file: &Path,
1121 leave_stub: bool,
1122 locality: bool,
1123 document_overrides: &HashMap<PathBuf, String>,
1124 workspace_roots: &[PathBuf],
1125) -> Result<RefactorPlan> {
1126 require_path_in_workspace(output_file, workspace_roots)?;
1128 if entity_iris.is_empty() {
1129 return Err(RefactorError::Invalid("no entities selected".to_string()));
1130 }
1131
1132 let expanded: Vec<String> = if locality {
1133 crate::ontology::expand_signature_locality(catalog, entity_iris)
1134 } else {
1135 entity_iris.to_vec()
1136 };
1137 let entity_iris = &expanded;
1138
1139 let mut blocks = Vec::new();
1140 let mut removals: Vec<EntityRemoval> = Vec::new();
1141 let mut source_texts: BTreeMap<PathBuf, String> = BTreeMap::new();
1142 let mut prefix_lines = BTreeSet::new();
1143 let mut warnings = Vec::new();
1144 if locality {
1145 warnings.push(format!(
1146 "locality expansion selected {} entit{}",
1147 entity_iris.len(),
1148 if entity_iris.len() == 1 { "y" } else { "ies" }
1149 ));
1150 }
1151
1152 for iri in entity_iris {
1153 let entity =
1154 catalog.find_entity(iri).ok_or_else(|| RefactorError::EntityNotFound(iri.clone()))?;
1155 let doc = catalog
1156 .entity_document(iri)
1157 .ok_or_else(|| RefactorError::Invalid(format!("no document for {iri}")))?;
1158 if doc.format != OntologyFormat::Turtle {
1159 return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
1160 }
1161 let text = if let Some(existing) = source_texts.get(&doc.path) {
1162 existing.clone()
1163 } else {
1164 read_source_text(&doc.path, document_overrides)?
1165 };
1166 source_texts.insert(doc.path.clone(), text.clone());
1167 for line in text.lines() {
1168 if is_prefix_declaration_line(line) {
1169 prefix_lines.insert(line.trim().to_string());
1170 }
1171 }
1172 let namespaces = strixonomy_owl::namespaces_for_text(&text, &doc.namespaces);
1173 let short = strixonomy_owl::short_name_from_iri(iri);
1174 let mut ranges =
1175 strixonomy_owl::all_entity_statement_ranges(&text, iri, &short, &namespaces);
1176 if ranges.is_empty() {
1177 return Err(RefactorError::Invalid(format!("block not found for {iri}")));
1178 }
1179 ranges.sort_by_key(|r| r.start);
1180 let mut entity_blocks = Vec::new();
1181 for (idx, range) in ranges.iter().enumerate() {
1182 let block = text[range.start as usize..range.end as usize].to_string();
1183 entity_blocks.push(block);
1184 let replacement = if leave_stub && idx == 0 {
1185 let owl_type = owl_type_for_kind(entity.kind);
1186 let moved_path = escape_turtle_string(&output_file.display().to_string());
1187 format!(
1188 "{} a {owl_type} ;\n owl:deprecated true ;\n rdfs:comment \"Moved to {moved_path}\" .\n",
1189 prefixed_curie(iri, &namespaces),
1190 )
1191 } else {
1192 String::new()
1193 };
1194 removals.push(EntityRemoval {
1195 path: doc.path.clone(),
1196 start: range.start,
1197 end: range.end,
1198 replacement,
1199 });
1200 }
1201 blocks.push(entity_blocks.join("\n"));
1202 }
1203
1204 let mut source_changes: BTreeMap<PathBuf, (String, String, Vec<Hunk>)> = BTreeMap::new();
1205 let mut removals_by_path: BTreeMap<PathBuf, Vec<EntityRemoval>> = BTreeMap::new();
1206 for removal in removals {
1207 removals_by_path.entry(removal.path.clone()).or_default().push(removal);
1208 }
1209 for (path, mut path_removals) in removals_by_path {
1210 path_removals.sort_by_key(|b| std::cmp::Reverse(b.start));
1211 let original = source_texts.remove(&path).ok_or_else(|| {
1212 RefactorError::Invalid(format!("missing source text for {}", path.display()))
1213 })?;
1214 let mut preview = original.clone();
1215 let mut hunks = Vec::new();
1216 for removal in path_removals {
1217 let start = removal.start as usize;
1218 let end = removal.end as usize;
1219 let old_text = preview[start..end].to_string();
1220 preview.replace_range(start..end, &removal.replacement);
1221 hunks.push(Hunk {
1222 start_byte: removal.start,
1223 end_byte: removal.end,
1224 old_text,
1225 new_text: removal.replacement.clone(),
1226 });
1227 }
1228 source_changes.insert(path, (original, preview, hunks));
1229 }
1230
1231 let mut module_body = blocks.join("\n\n");
1232 if !module_body.ends_with('\n') {
1233 module_body.push('\n');
1234 }
1235 let prefix_header: String = prefix_lines.into_iter().collect::<Vec<_>>().join("\n");
1236 let module_text = if prefix_header.is_empty() {
1237 module_body
1238 } else {
1239 format!("{prefix_header}\n\n{module_body}")
1240 };
1241
1242 let mut changes: Vec<FileChange> = source_changes
1243 .into_iter()
1244 .map(|(path, (original, preview, hunks))| FileChange {
1245 path: path.clone(),
1246 preview_text: preview,
1247 original_text: original,
1248 hunks,
1249 })
1250 .collect();
1251
1252 let output_original = if output_file.exists() {
1253 read_source_text(output_file, document_overrides)?
1254 } else {
1255 String::new()
1256 };
1257 let output_preview = if output_original.is_empty() {
1258 module_text.clone()
1259 } else {
1260 format!("{output_original}\n\n{module_text}")
1261 };
1262 changes.push(FileChange {
1263 path: output_file.to_path_buf(),
1264 preview_text: output_preview,
1265 original_text: output_original,
1266 hunks: vec![Hunk {
1267 start_byte: 0,
1268 end_byte: 0,
1269 old_text: String::new(),
1270 new_text: module_text,
1271 }],
1272 });
1273
1274 if leave_stub {
1275 warnings.push("left deprecated stubs in source files".to_string());
1276 }
1277
1278 Ok(RefactorPlan { changes, warnings, ..Default::default() }
1279 .with_metrics(entity_iris.iter().map(|s| s.as_str())))
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284 use super::*;
1285
1286 #[test]
1287 fn replace_prefix_uri_updates_at_prefix_uppercase() {
1288 let text = "@PREFIX ex: <http://example.org/org#> .\nex:Person a owl:Class .\n";
1289 let (out, hunks) =
1290 replace_prefix_uri(text, "ex", "http://example.org/org#", "http://example.org/v2/org#");
1291 assert!(out.contains("@PREFIX ex: <http://example.org/v2/org#>"));
1292 assert!(!out.contains("@PREFIX ex: <http://example.org/org#>"));
1293 assert!(!hunks.is_empty());
1294 }
1295
1296 #[test]
1297 fn replace_prefix_uri_updates_sparql_style_prefix() {
1298 let text = "PREFIX ex: <http://example.org/org#>\nex:Person a owl:Class .\n";
1299 let (out, _) =
1300 replace_prefix_uri(text, "ex", "http://example.org/org#", "http://example.org/v2/org#");
1301 assert!(out.contains("PREFIX ex: <http://example.org/v2/org#>"));
1302 }
1303
1304 #[test]
1305 fn owl_type_for_kind_maps_ontology() {
1306 assert_eq!(owl_type_for_kind(EntityKind::Ontology), "owl:Ontology");
1307 assert_eq!(owl_type_for_kind(EntityKind::Other), "owl:Class");
1308 assert_eq!(owl_type_for_kind(EntityKind::Class), "owl:Class");
1309 }
1310
1311 #[test]
1312 fn escape_turtle_string_escapes_path_specials() {
1313 assert_eq!(
1314 escape_turtle_string(r#"C:\ontology\mod"ule.ttl"#),
1315 r#"C:\\ontology\\mod\"ule.ttl"#
1316 );
1317 }
1318}