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 ontocore_catalog::OntologyCatalog;
6use ontocore_core::{validate_workspace_scope_any, EntityKind, OntologyFormat, ParseStatus};
7use std::collections::{BTreeMap, BTreeSet, HashMap};
8use std::path::{Path, PathBuf};
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
28 for doc in &catalog.data().documents {
29 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
30 if text_contains_iri(doc, from_iri, document_overrides) {
31 warnings
32 .push(format!("skipping non-Turtle or errored file: {}", doc.path.display()));
33 }
34 continue;
35 }
36 let original = read_source_text(&doc.path, document_overrides)?;
37 if !original.contains(from_iri)
38 && !contains_prefixed_ref(&original, from_iri, &doc.namespaces)
39 {
40 continue;
41 }
42 let (preview_text, raw_hunks) =
43 replace_iri_in_text(&original, from_iri, to_iri, &doc.namespaces);
44 if preview_text == original {
45 continue;
46 }
47 let hunks: Vec<Hunk> = raw_hunks
48 .into_iter()
49 .map(|(start, end, old_text, new_text)| Hunk {
50 start_byte: start as u64,
51 end_byte: end as u64,
52 old_text,
53 new_text,
54 })
55 .collect();
56 file_changes.insert(
57 doc.path.clone(),
58 FileChange { path: doc.path.clone(), preview_text, original_text: original, hunks },
59 );
60 }
61
62 if file_changes.is_empty() {
63 warnings.push(format!("no Turtle files changed for IRI {from_iri}"));
64 }
65
66 Ok(RefactorPlan { changes: file_changes.into_values().collect(), warnings })
67}
68
69pub fn preview_merge_entities(
70 catalog: &OntologyCatalog,
71 keep_iri: &str,
72 merge_iri: &str,
73 document_overrides: &HashMap<PathBuf, String>,
74) -> Result<RefactorPlan> {
75 if keep_iri == merge_iri {
76 return Err(RefactorError::Invalid("keep and merge IRI must differ".to_string()));
77 }
78
79 let mut warnings = Vec::new();
80 if catalog.find_entity(keep_iri).is_none() {
81 warnings.push(format!("keep entity not found: {keep_iri}"));
82 }
83 if catalog.find_entity(merge_iri).is_none() {
84 warnings.push(format!("merge entity not found: {merge_iri}"));
85 }
86
87 let mut changes = BTreeMap::new();
88 for doc in &catalog.data().documents {
89 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
90 if text_contains_iri(doc, merge_iri, document_overrides) {
91 warnings
92 .push(format!("skipping non-Turtle or errored file: {}", doc.path.display()));
93 }
94 continue;
95 }
96
97 let original = read_source_text(&doc.path, document_overrides)?;
98 if !original.contains(merge_iri)
99 && !contains_prefixed_ref(&original, merge_iri, &doc.namespaces)
100 {
101 continue;
102 }
103
104 let namespaces = ontocore_owl::namespaces_for_text(&original, &doc.namespaces);
105 let short = ontocore_owl::short_name_from_iri(merge_iri);
106 let mut declaration_ranges =
107 ontocore_owl::all_entity_statement_ranges(&original, merge_iri, &short, &namespaces);
108 declaration_ranges.sort_by_key(|range| std::cmp::Reverse(range.start));
109
110 let mut without_merge_declaration = original.clone();
111 for range in declaration_ranges {
112 without_merge_declaration.replace_range(range.start as usize..range.end as usize, "");
113 }
114 let (preview_text, _) =
115 replace_iri_in_text(&without_merge_declaration, merge_iri, keep_iri, &doc.namespaces);
116 if preview_text == original {
117 continue;
118 }
119
120 changes
121 .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview_text));
122 }
123
124 if changes.is_empty() {
125 warnings
126 .push(format!("no Turtle files changed for entity merge {merge_iri} -> {keep_iri}"));
127 }
128
129 Ok(RefactorPlan { changes: changes.into_values().collect(), warnings })
130}
131
132pub fn preview_replace_entity(
133 catalog: &OntologyCatalog,
134 from_iri: &str,
135 to_iri: &str,
136 document_overrides: &HashMap<PathBuf, String>,
137) -> Result<RefactorPlan> {
138 if catalog.find_entity(to_iri).is_none() {
139 return preview_rename_iri(catalog, from_iri, to_iri, document_overrides);
140 }
141 if from_iri == to_iri {
142 return Err(RefactorError::Invalid("from and to IRI must differ".to_string()));
143 }
144 if catalog.find_entity(from_iri).is_none()
145 && find_usages_in_catalog(catalog, from_iri, document_overrides).is_empty()
146 {
147 return Err(RefactorError::EntityNotFound(from_iri.to_string()));
148 }
149
150 let mut changes = BTreeMap::new();
151 let mut warnings = Vec::new();
152 for doc in &catalog.data().documents {
153 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
154 if text_contains_iri(doc, from_iri, document_overrides) {
155 warnings
156 .push(format!("skipping non-Turtle or errored file: {}", doc.path.display()));
157 }
158 continue;
159 }
160
161 let original = read_source_text(&doc.path, document_overrides)?;
162 if !original.contains(from_iri)
163 && !contains_prefixed_ref(&original, from_iri, &doc.namespaces)
164 {
165 continue;
166 }
167
168 let namespaces = ontocore_owl::namespaces_for_text(&original, &doc.namespaces);
169 let declaration_start =
170 ontocore_owl::entity_primary_block_range(&original, from_iri, &namespaces)
171 .map(|range| range.start as usize);
172 let preview_text = replace_iri_preserving_subject(
173 &original,
174 from_iri,
175 to_iri,
176 &doc.namespaces,
177 declaration_start,
178 );
179 if preview_text == original {
180 continue;
181 }
182
183 changes
184 .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview_text));
185 }
186
187 if changes.is_empty() {
188 warnings
189 .push(format!("no Turtle files changed for entity replacement {from_iri} -> {to_iri}"));
190 }
191
192 Ok(RefactorPlan { changes: changes.into_values().collect(), warnings })
193}
194
195fn replace_iri_preserving_subject(
196 text: &str,
197 from_iri: &str,
198 to_iri: &str,
199 namespaces: &BTreeMap<String, String>,
200 subject_start: Option<usize>,
201) -> String {
202 let Some(subject_start) = subject_start else {
203 return replace_iri_in_text(text, from_iri, to_iri, namespaces).0;
204 };
205 let subject_text = &text[subject_start..];
206 let subject_end = if subject_text.starts_with('<') {
207 subject_text.find('>').map(|offset| subject_start + offset + 1).unwrap_or(text.len())
208 } else {
209 subject_text
210 .find(|c: char| c.is_whitespace() || c == ';')
211 .map(|offset| subject_start + offset)
212 .unwrap_or(text.len())
213 };
214
215 let before = replace_iri_in_text(&text[..subject_start], from_iri, to_iri, namespaces).0;
216 let after = replace_iri_in_text(&text[subject_end..], from_iri, to_iri, namespaces).0;
217 format!("{before}{}{after}", &text[subject_start..subject_end])
218}
219
220fn whole_file_change(path: PathBuf, original_text: String, preview_text: String) -> FileChange {
221 FileChange {
222 path,
223 hunks: vec![Hunk {
224 start_byte: 0,
225 end_byte: original_text.len() as u64,
226 old_text: original_text.clone(),
227 new_text: preview_text.clone(),
228 }],
229 preview_text,
230 original_text,
231 }
232}
233
234pub fn preview_migrate_namespace(
235 catalog: &OntologyCatalog,
236 from_base: &str,
237 to_base: &str,
238 document_overrides: &HashMap<PathBuf, String>,
239) -> Result<RefactorPlan> {
240 let from = normalize_namespace_base(from_base);
241 let to = normalize_namespace_base(to_base);
242 if from == to {
243 return Err(RefactorError::Invalid("from and to namespace must differ".to_string()));
244 }
245
246 let all_iris: Vec<String> = catalog
247 .data()
248 .entities
249 .iter()
250 .filter(|e| remap_iri(&e.iri, &from, &to).is_some())
251 .map(|e| e.iri.clone())
252 .chain(catalog.data().axioms.iter().flat_map(|a| {
253 let mut v = Vec::new();
254 if remap_iri(&a.subject, &from, &to).is_some() {
255 v.push(a.subject.clone());
256 }
257 if remap_iri(&a.object, &from, &to).is_some() {
258 v.push(a.object.clone());
259 }
260 v
261 }))
262 .collect::<BTreeSet<_>>()
263 .into_iter()
264 .collect();
265
266 let mut changes: BTreeMap<PathBuf, FileChange> = BTreeMap::new();
267 let mut warnings = Vec::new();
268
269 for doc in &catalog.data().documents {
270 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
271 continue;
272 }
273 let original = read_source_text(&doc.path, document_overrides)?;
274 let mut preview = original.clone();
275 let mut hunks = Vec::new();
276 let mut changed = false;
277
278 for old_iri in &all_iris {
279 let new_iri = remap_iri(old_iri, &from, &to).unwrap_or_else(|| old_iri.clone());
280 if !preview.contains(old_iri)
281 && !contains_prefixed_ref(&preview, old_iri, &doc.namespaces)
282 {
283 continue;
284 }
285 let (next, raw_hunks) =
286 replace_iri_in_text(&preview, old_iri, &new_iri, &doc.namespaces);
287 if next != preview {
288 preview = next;
289 changed = true;
290 hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
291 start_byte: s as u64,
292 end_byte: e as u64,
293 old_text: o,
294 new_text: n,
295 }));
296 }
297 }
298
299 for (prefix, ns) in &doc.namespaces {
300 if normalize_namespace_base(ns) == from {
301 let terminator = if ns.ends_with('/') { '/' } else { '#' };
302 let new_ns = format!("{to}{terminator}");
303 let (next, raw_hunks) = replace_prefix_uri(&preview, prefix, ns, &new_ns);
304 if next != preview {
305 preview = next;
306 changed = true;
307 hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
308 start_byte: s as u64,
309 end_byte: e as u64,
310 old_text: o,
311 new_text: n,
312 }));
313 }
314 }
315 }
316
317 if changed {
318 changes.insert(
319 doc.path.clone(),
320 FileChange {
321 path: doc.path.clone(),
322 preview_text: preview,
323 original_text: original,
324 hunks,
325 },
326 );
327 }
328 }
329
330 if changes.is_empty() {
331 warnings.push(format!("no Turtle files changed for namespace migration {from} -> {to}"));
332 }
333
334 Ok(RefactorPlan { changes: changes.into_values().collect(), warnings })
335}
336
337fn is_prefix_declaration_line(line: &str) -> bool {
338 let keyword = line.split_whitespace().next().unwrap_or("");
339 keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")
340}
341
342fn prefix_declaration_name(line: &str) -> Option<&str> {
343 let mut parts = line.split_whitespace();
344 let keyword = parts.next()?;
345 if !(keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")) {
346 return None;
347 }
348 parts.next()?.strip_suffix(':')
349}
350
351fn replace_prefix_uri(
352 text: &str,
353 prefix: &str,
354 old_uri: &str,
355 new_uri: &str,
356) -> (String, Vec<(usize, usize, String, String)>) {
357 let old_term = format!("<{old_uri}>");
358 let new_term = format!("<{new_uri}>");
359 let mut out = String::with_capacity(text.len());
360 let mut hunks = Vec::new();
361 let mut offset = 0usize;
362 for line in text.split_inclusive('\n') {
363 let updated = if prefix_declaration_name(line) == Some(prefix) && line.contains(&old_term) {
364 line.replacen(&old_term, &new_term, 1)
365 } else {
366 line.to_string()
367 };
368 if updated != line {
369 hunks.push((offset, offset + line.len(), line.to_string(), updated.clone()));
370 }
371 out.push_str(&updated);
372 offset += line.len();
373 }
374 (out, hunks)
375}
376
377fn escape_turtle_string(value: &str) -> String {
378 let mut out = String::with_capacity(value.len());
379 for ch in value.chars() {
380 match ch {
381 '\\' => out.push_str("\\\\"),
382 '"' => out.push_str("\\\""),
383 '\n' => out.push_str("\\n"),
384 '\r' => out.push_str("\\r"),
385 '\t' => out.push_str("\\t"),
386 c => out.push(c),
387 }
388 }
389 out
390}
391
392fn find_usages_in_catalog(
393 catalog: &OntologyCatalog,
394 iri: &str,
395 document_overrides: &HashMap<PathBuf, String>,
396) -> Vec<()> {
397 let u = crate::usages::find_usages_with_overrides(catalog, iri, document_overrides);
398 vec![(); u.len()]
399}
400
401fn text_contains_iri(
402 doc: &ontocore_core::OntologyDocument,
403 iri: &str,
404 document_overrides: &HashMap<PathBuf, String>,
405) -> bool {
406 read_source_text(&doc.path, document_overrides).map(|t| t.contains(iri)).unwrap_or(false)
407}
408
409fn contains_prefixed_ref(text: &str, iri: &str, namespaces: &BTreeMap<String, String>) -> bool {
410 let short = ontocore_owl::short_name_from_iri(iri);
411 for (prefix, ns) in namespaces {
412 if iri.starts_with(ns) && text.contains(&format!("{prefix}:{short}")) {
413 return true;
414 }
415 }
416 false
417}
418
419fn prefixed_curie(iri: &str, namespaces: &BTreeMap<String, String>) -> String {
420 let short = ontocore_owl::short_name_from_iri(iri);
421 for (prefix, ns) in namespaces {
422 if iri.starts_with(ns) && !prefix.is_empty() {
423 return format!("{prefix}:{short}");
424 }
425 }
426 format!("<{iri}>")
427}
428
429fn owl_type_for_kind(kind: EntityKind) -> &'static str {
430 match kind {
431 EntityKind::Class => "owl:Class",
432 EntityKind::ObjectProperty => "owl:ObjectProperty",
433 EntityKind::DataProperty => "owl:DatatypeProperty",
434 EntityKind::AnnotationProperty => "owl:AnnotationProperty",
435 EntityKind::Individual => "owl:NamedIndividual",
436 EntityKind::Datatype => "rdfs:Datatype",
437 EntityKind::Ontology => "owl:Ontology",
438 EntityKind::Other => "owl:Class",
439 }
440}
441
442struct EntityRemoval {
443 path: PathBuf,
444 start: u64,
445 end: u64,
446 replacement: String,
447}
448
449pub fn preview_refactor(
452 catalog: &OntologyCatalog,
453 request: &crate::model::RefactorRequest,
454 document_overrides: &HashMap<PathBuf, String>,
455 workspace_roots: &[PathBuf],
456) -> Result<RefactorPlan> {
457 match request {
458 crate::model::RefactorRequest::RenameIri { from_iri, to_iri } => {
459 preview_rename_iri(catalog, from_iri, to_iri, document_overrides)
460 }
461 crate::model::RefactorRequest::MergeEntities { keep_iri, merge_iri } => {
462 preview_merge_entities(catalog, keep_iri, merge_iri, document_overrides)
463 }
464 crate::model::RefactorRequest::ReplaceEntity { from_iri, to_iri } => {
465 preview_replace_entity(catalog, from_iri, to_iri, document_overrides)
466 }
467 crate::model::RefactorRequest::MigrateNamespace { from_base, to_base } => {
468 preview_migrate_namespace(catalog, from_base, to_base, document_overrides)
469 }
470 crate::model::RefactorRequest::MoveEntity { entity_iri, target_file } => {
471 preview_move_entity(
472 catalog,
473 entity_iri,
474 target_file,
475 document_overrides,
476 workspace_roots,
477 )
478 }
479 crate::model::RefactorRequest::ExtractModule { entity_iris, output_file, leave_stub } => {
480 preview_extract_module(
481 catalog,
482 entity_iris,
483 output_file,
484 *leave_stub,
485 document_overrides,
486 workspace_roots,
487 )
488 }
489 }
490}
491
492fn require_path_in_workspace(path: &Path, workspace_roots: &[PathBuf]) -> Result<()> {
493 validate_workspace_scope_any(path, workspace_roots).map_err(RefactorError::Invalid)?;
494 Ok(())
495}
496
497fn canonical_path(path: &Path) -> PathBuf {
498 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
499}
500
501pub fn preview_move_entity(
502 catalog: &OntologyCatalog,
503 entity_iri: &str,
504 target_file: &Path,
505 document_overrides: &HashMap<PathBuf, String>,
506 workspace_roots: &[PathBuf],
507) -> Result<RefactorPlan> {
508 require_path_in_workspace(target_file, workspace_roots)?;
510 catalog
511 .find_entity(entity_iri)
512 .ok_or_else(|| RefactorError::EntityNotFound(entity_iri.to_string()))?;
513 let source_doc = catalog
514 .entity_document(entity_iri)
515 .ok_or_else(|| RefactorError::Invalid(format!("no document for {entity_iri}")))?;
516 if source_doc.format != OntologyFormat::Turtle {
517 return Err(RefactorError::UnsupportedFormat(source_doc.format.as_str().to_string()));
518 }
519
520 let source_canon = canonical_path(&source_doc.path);
521 let target_canon = canonical_path(target_file);
522 if source_canon == target_canon {
523 return Err(RefactorError::Invalid(
524 "target file must differ from source document".to_string(),
525 ));
526 }
527
528 let source_text = read_source_text(&source_doc.path, document_overrides)?;
529 let namespaces = ontocore_owl::namespaces_for_text(&source_text, &source_doc.namespaces);
530 let short = ontocore_owl::short_name_from_iri(entity_iri);
531 let mut ranges =
532 ontocore_owl::all_entity_statement_ranges(&source_text, entity_iri, &short, &namespaces);
533 if ranges.is_empty() {
534 return Err(RefactorError::Invalid(format!("entity block not found for {entity_iri}")));
535 }
536 ranges.sort_by_key(|r| r.start);
537
538 let mut block_parts = Vec::new();
539 let mut hunks = Vec::new();
540 for range in &ranges {
541 let part = source_text[range.start as usize..range.end as usize].to_string();
542 block_parts.push(part.clone());
543 hunks.push(Hunk {
544 start_byte: range.start,
545 end_byte: range.end,
546 old_text: part,
547 new_text: String::new(),
548 });
549 }
550 let block_text = block_parts.join("\n");
551 let mut source_without = source_text.clone();
552 for range in ranges.into_iter().rev() {
553 source_without.replace_range(range.start as usize..range.end as usize, "");
554 }
555
556 let mut prefix_lines = BTreeSet::new();
557 for line in source_text.lines() {
558 if is_prefix_declaration_line(line) {
559 prefix_lines.insert(line.trim().to_string());
560 }
561 }
562 let prefix_header: String = prefix_lines.iter().cloned().collect::<Vec<_>>().join("\n");
563 let block_with_prefixes = if prefix_header.is_empty() {
564 block_text.clone()
565 } else {
566 format!("{prefix_header}\n\n{block_text}")
567 };
568
569 let target_original = if target_file.exists() {
570 read_source_text(target_file, document_overrides)?
571 } else {
572 String::new()
573 };
574 let target_was_empty = target_original.is_empty();
575 let mut warnings = Vec::new();
576 let (target_preview, target_hunk_new) = if target_was_empty {
577 let mut out = block_with_prefixes.clone();
578 if !out.ends_with('\n') {
579 out.push('\n');
580 }
581 (out, block_with_prefixes)
582 } else {
583 let target_prefix_names: BTreeSet<String> = target_original
585 .lines()
586 .filter_map(prefix_declaration_name)
587 .map(str::to_string)
588 .collect();
589 let mut missing_prefixes = Vec::new();
590 for line in &prefix_lines {
591 let Some(name) = prefix_declaration_name(line) else {
592 continue;
593 };
594 if !target_prefix_names.contains(name) {
595 missing_prefixes.push(line.clone());
596 }
597 }
598 let inserted = if missing_prefixes.is_empty() {
599 String::new()
600 } else {
601 format!("{}\n\n", missing_prefixes.join("\n"))
602 };
603 if !missing_prefixes.is_empty() {
604 warnings.push(format!(
605 "added {} missing @prefix declaration(s) to {}",
606 missing_prefixes.len(),
607 target_file.display()
608 ));
609 }
610 let hunk_new = format!("{inserted}{block_text}");
611 (format!("{target_original}\n\n{hunk_new}"), hunk_new)
612 };
613
614 Ok(RefactorPlan {
615 changes: vec![
616 FileChange {
617 path: source_doc.path.clone(),
618 preview_text: source_without,
619 original_text: source_text,
620 hunks,
621 },
622 FileChange {
623 path: target_file.to_path_buf(),
624 preview_text: target_preview,
625 original_text: target_original,
626 hunks: vec![Hunk {
627 start_byte: 0,
628 end_byte: 0,
629 old_text: String::new(),
630 new_text: target_hunk_new,
631 }],
632 },
633 ],
634 warnings,
635 })
636}
637
638pub fn preview_extract_module(
639 catalog: &OntologyCatalog,
640 entity_iris: &[String],
641 output_file: &Path,
642 leave_stub: bool,
643 document_overrides: &HashMap<PathBuf, String>,
644 workspace_roots: &[PathBuf],
645) -> Result<RefactorPlan> {
646 require_path_in_workspace(output_file, workspace_roots)?;
648 if entity_iris.is_empty() {
649 return Err(RefactorError::Invalid("no entities selected".to_string()));
650 }
651
652 let mut blocks = Vec::new();
653 let mut removals: Vec<EntityRemoval> = Vec::new();
654 let mut source_texts: BTreeMap<PathBuf, String> = BTreeMap::new();
655 let mut prefix_lines = BTreeSet::new();
656 let mut warnings = Vec::new();
657
658 for iri in entity_iris {
659 let entity =
660 catalog.find_entity(iri).ok_or_else(|| RefactorError::EntityNotFound(iri.clone()))?;
661 let doc = catalog
662 .entity_document(iri)
663 .ok_or_else(|| RefactorError::Invalid(format!("no document for {iri}")))?;
664 if doc.format != OntologyFormat::Turtle {
665 return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
666 }
667 let text = if let Some(existing) = source_texts.get(&doc.path) {
668 existing.clone()
669 } else {
670 read_source_text(&doc.path, document_overrides)?
671 };
672 source_texts.insert(doc.path.clone(), text.clone());
673 for line in text.lines() {
674 if is_prefix_declaration_line(line) {
675 prefix_lines.insert(line.trim().to_string());
676 }
677 }
678 let namespaces = ontocore_owl::namespaces_for_text(&text, &doc.namespaces);
679 let short = ontocore_owl::short_name_from_iri(iri);
680 let mut ranges = ontocore_owl::all_entity_statement_ranges(&text, iri, &short, &namespaces);
681 if ranges.is_empty() {
682 return Err(RefactorError::Invalid(format!("block not found for {iri}")));
683 }
684 ranges.sort_by_key(|r| r.start);
685 let mut entity_blocks = Vec::new();
686 for (idx, range) in ranges.iter().enumerate() {
687 let block = text[range.start as usize..range.end as usize].to_string();
688 entity_blocks.push(block);
689 let replacement = if leave_stub && idx == 0 {
690 let owl_type = owl_type_for_kind(entity.kind);
691 let moved_path = escape_turtle_string(&output_file.display().to_string());
692 format!(
693 "{} a {owl_type} ;\n owl:deprecated true ;\n rdfs:comment \"Moved to {moved_path}\" .\n",
694 prefixed_curie(iri, &namespaces),
695 )
696 } else {
697 String::new()
698 };
699 removals.push(EntityRemoval {
700 path: doc.path.clone(),
701 start: range.start,
702 end: range.end,
703 replacement,
704 });
705 }
706 blocks.push(entity_blocks.join("\n"));
707 }
708
709 let mut source_changes: BTreeMap<PathBuf, (String, String, Vec<Hunk>)> = BTreeMap::new();
710 let mut removals_by_path: BTreeMap<PathBuf, Vec<EntityRemoval>> = BTreeMap::new();
711 for removal in removals {
712 removals_by_path.entry(removal.path.clone()).or_default().push(removal);
713 }
714 for (path, mut path_removals) in removals_by_path {
715 path_removals.sort_by_key(|b| std::cmp::Reverse(b.start));
716 let original = source_texts.remove(&path).ok_or_else(|| {
717 RefactorError::Invalid(format!("missing source text for {}", path.display()))
718 })?;
719 let mut preview = original.clone();
720 let mut hunks = Vec::new();
721 for removal in path_removals {
722 let start = removal.start as usize;
723 let end = removal.end as usize;
724 let old_text = preview[start..end].to_string();
725 preview.replace_range(start..end, &removal.replacement);
726 hunks.push(Hunk {
727 start_byte: removal.start,
728 end_byte: removal.end,
729 old_text,
730 new_text: removal.replacement.clone(),
731 });
732 }
733 source_changes.insert(path, (original, preview, hunks));
734 }
735
736 let mut module_body = blocks.join("\n\n");
737 if !module_body.ends_with('\n') {
738 module_body.push('\n');
739 }
740 let prefix_header: String = prefix_lines.into_iter().collect::<Vec<_>>().join("\n");
741 let module_text = if prefix_header.is_empty() {
742 module_body
743 } else {
744 format!("{prefix_header}\n\n{module_body}")
745 };
746
747 let mut changes: Vec<FileChange> = source_changes
748 .into_iter()
749 .map(|(path, (original, preview, hunks))| FileChange {
750 path: path.clone(),
751 preview_text: preview,
752 original_text: original,
753 hunks,
754 })
755 .collect();
756
757 let output_original = if output_file.exists() {
758 read_source_text(output_file, document_overrides)?
759 } else {
760 String::new()
761 };
762 let output_preview = if output_original.is_empty() {
763 module_text.clone()
764 } else {
765 format!("{output_original}\n\n{module_text}")
766 };
767 changes.push(FileChange {
768 path: output_file.to_path_buf(),
769 preview_text: output_preview,
770 original_text: output_original,
771 hunks: vec![Hunk {
772 start_byte: 0,
773 end_byte: 0,
774 old_text: String::new(),
775 new_text: module_text,
776 }],
777 });
778
779 if leave_stub {
780 warnings.push("left deprecated stubs in source files".to_string());
781 }
782
783 Ok(RefactorPlan { changes, warnings })
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 #[test]
791 fn replace_prefix_uri_updates_at_prefix_uppercase() {
792 let text = "@PREFIX ex: <http://example.org/org#> .\nex:Person a owl:Class .\n";
793 let (out, hunks) =
794 replace_prefix_uri(text, "ex", "http://example.org/org#", "http://example.org/v2/org#");
795 assert!(out.contains("@PREFIX ex: <http://example.org/v2/org#>"));
796 assert!(!out.contains("@PREFIX ex: <http://example.org/org#>"));
797 assert!(!hunks.is_empty());
798 }
799
800 #[test]
801 fn replace_prefix_uri_updates_sparql_style_prefix() {
802 let text = "PREFIX ex: <http://example.org/org#>\nex:Person a owl:Class .\n";
803 let (out, _) =
804 replace_prefix_uri(text, "ex", "http://example.org/org#", "http://example.org/v2/org#");
805 assert!(out.contains("PREFIX ex: <http://example.org/v2/org#>"));
806 }
807
808 #[test]
809 fn owl_type_for_kind_maps_ontology() {
810 assert_eq!(owl_type_for_kind(EntityKind::Ontology), "owl:Ontology");
811 assert_eq!(owl_type_for_kind(EntityKind::Other), "owl:Class");
812 assert_eq!(owl_type_for_kind(EntityKind::Class), "owl:Class");
813 }
814
815 #[test]
816 fn escape_turtle_string_escapes_path_specials() {
817 assert_eq!(
818 escape_turtle_string(r#"C:\ontology\mod"ule.ttl"#),
819 r#"C:\\ontology\\mod\"ule.ttl"#
820 );
821 }
822}