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::Ontology => "owl:Ontology",
437 EntityKind::Other => "owl:Class",
438 }
439}
440
441struct EntityRemoval {
442 path: PathBuf,
443 start: u64,
444 end: u64,
445 replacement: String,
446}
447
448pub fn preview_refactor(
451 catalog: &OntologyCatalog,
452 request: &crate::model::RefactorRequest,
453 document_overrides: &HashMap<PathBuf, String>,
454 workspace_roots: &[PathBuf],
455) -> Result<RefactorPlan> {
456 match request {
457 crate::model::RefactorRequest::RenameIri { from_iri, to_iri } => {
458 preview_rename_iri(catalog, from_iri, to_iri, document_overrides)
459 }
460 crate::model::RefactorRequest::MergeEntities { keep_iri, merge_iri } => {
461 preview_merge_entities(catalog, keep_iri, merge_iri, document_overrides)
462 }
463 crate::model::RefactorRequest::ReplaceEntity { from_iri, to_iri } => {
464 preview_replace_entity(catalog, from_iri, to_iri, document_overrides)
465 }
466 crate::model::RefactorRequest::MigrateNamespace { from_base, to_base } => {
467 preview_migrate_namespace(catalog, from_base, to_base, document_overrides)
468 }
469 crate::model::RefactorRequest::MoveEntity { entity_iri, target_file } => {
470 preview_move_entity(
471 catalog,
472 entity_iri,
473 target_file,
474 document_overrides,
475 workspace_roots,
476 )
477 }
478 crate::model::RefactorRequest::ExtractModule { entity_iris, output_file, leave_stub } => {
479 preview_extract_module(
480 catalog,
481 entity_iris,
482 output_file,
483 *leave_stub,
484 document_overrides,
485 workspace_roots,
486 )
487 }
488 }
489}
490
491fn require_path_in_workspace(path: &Path, workspace_roots: &[PathBuf]) -> Result<()> {
492 validate_workspace_scope_any(path, workspace_roots).map_err(RefactorError::Invalid)?;
493 Ok(())
494}
495
496fn canonical_path(path: &Path) -> PathBuf {
497 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
498}
499
500pub fn preview_move_entity(
501 catalog: &OntologyCatalog,
502 entity_iri: &str,
503 target_file: &Path,
504 document_overrides: &HashMap<PathBuf, String>,
505 workspace_roots: &[PathBuf],
506) -> Result<RefactorPlan> {
507 require_path_in_workspace(target_file, workspace_roots)?;
509 catalog
510 .find_entity(entity_iri)
511 .ok_or_else(|| RefactorError::EntityNotFound(entity_iri.to_string()))?;
512 let source_doc = catalog
513 .entity_document(entity_iri)
514 .ok_or_else(|| RefactorError::Invalid(format!("no document for {entity_iri}")))?;
515 if source_doc.format != OntologyFormat::Turtle {
516 return Err(RefactorError::UnsupportedFormat(source_doc.format.as_str().to_string()));
517 }
518
519 let source_canon = canonical_path(&source_doc.path);
520 let target_canon = canonical_path(target_file);
521 if source_canon == target_canon {
522 return Err(RefactorError::Invalid(
523 "target file must differ from source document".to_string(),
524 ));
525 }
526
527 let source_text = read_source_text(&source_doc.path, document_overrides)?;
528 let namespaces = ontocore_owl::namespaces_for_text(&source_text, &source_doc.namespaces);
529 let short = ontocore_owl::short_name_from_iri(entity_iri);
530 let mut ranges =
531 ontocore_owl::all_entity_statement_ranges(&source_text, entity_iri, &short, &namespaces);
532 if ranges.is_empty() {
533 return Err(RefactorError::Invalid(format!("entity block not found for {entity_iri}")));
534 }
535 ranges.sort_by_key(|r| r.start);
536
537 let mut block_parts = Vec::new();
538 let mut hunks = Vec::new();
539 for range in &ranges {
540 let part = source_text[range.start as usize..range.end as usize].to_string();
541 block_parts.push(part.clone());
542 hunks.push(Hunk {
543 start_byte: range.start,
544 end_byte: range.end,
545 old_text: part,
546 new_text: String::new(),
547 });
548 }
549 let block_text = block_parts.join("\n");
550 let mut source_without = source_text.clone();
551 for range in ranges.into_iter().rev() {
552 source_without.replace_range(range.start as usize..range.end as usize, "");
553 }
554
555 let target_original = if target_file.exists() {
556 read_source_text(target_file, document_overrides)?
557 } else {
558 String::new()
559 };
560 let target_preview = if target_original.is_empty() {
561 format!("{block_text}\n")
562 } else {
563 format!("{target_original}\n\n{block_text}")
564 };
565
566 Ok(RefactorPlan {
567 changes: vec![
568 FileChange {
569 path: source_doc.path.clone(),
570 preview_text: source_without,
571 original_text: source_text,
572 hunks,
573 },
574 FileChange {
575 path: target_file.to_path_buf(),
576 preview_text: target_preview,
577 original_text: target_original,
578 hunks: vec![Hunk {
579 start_byte: 0,
580 end_byte: 0,
581 old_text: String::new(),
582 new_text: block_text,
583 }],
584 },
585 ],
586 warnings: Vec::new(),
587 })
588}
589
590pub fn preview_extract_module(
591 catalog: &OntologyCatalog,
592 entity_iris: &[String],
593 output_file: &Path,
594 leave_stub: bool,
595 document_overrides: &HashMap<PathBuf, String>,
596 workspace_roots: &[PathBuf],
597) -> Result<RefactorPlan> {
598 require_path_in_workspace(output_file, workspace_roots)?;
600 if entity_iris.is_empty() {
601 return Err(RefactorError::Invalid("no entities selected".to_string()));
602 }
603
604 let mut blocks = Vec::new();
605 let mut removals: Vec<EntityRemoval> = Vec::new();
606 let mut source_texts: BTreeMap<PathBuf, String> = BTreeMap::new();
607 let mut prefix_lines = BTreeSet::new();
608 let mut warnings = Vec::new();
609
610 for iri in entity_iris {
611 let entity =
612 catalog.find_entity(iri).ok_or_else(|| RefactorError::EntityNotFound(iri.clone()))?;
613 let doc = catalog
614 .entity_document(iri)
615 .ok_or_else(|| RefactorError::Invalid(format!("no document for {iri}")))?;
616 if doc.format != OntologyFormat::Turtle {
617 return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
618 }
619 let text = if let Some(existing) = source_texts.get(&doc.path) {
620 existing.clone()
621 } else {
622 read_source_text(&doc.path, document_overrides)?
623 };
624 source_texts.insert(doc.path.clone(), text.clone());
625 for line in text.lines() {
626 if is_prefix_declaration_line(line) {
627 prefix_lines.insert(line.trim().to_string());
628 }
629 }
630 let namespaces = ontocore_owl::namespaces_for_text(&text, &doc.namespaces);
631 let short = ontocore_owl::short_name_from_iri(iri);
632 let mut ranges = ontocore_owl::all_entity_statement_ranges(&text, iri, &short, &namespaces);
633 if ranges.is_empty() {
634 return Err(RefactorError::Invalid(format!("block not found for {iri}")));
635 }
636 ranges.sort_by_key(|r| r.start);
637 let mut entity_blocks = Vec::new();
638 for (idx, range) in ranges.iter().enumerate() {
639 let block = text[range.start as usize..range.end as usize].to_string();
640 entity_blocks.push(block);
641 let replacement = if leave_stub && idx == 0 {
642 let owl_type = owl_type_for_kind(entity.kind);
643 let moved_path = escape_turtle_string(&output_file.display().to_string());
644 format!(
645 "{} a {owl_type} ;\n owl:deprecated true ;\n rdfs:comment \"Moved to {moved_path}\" .\n",
646 prefixed_curie(iri, &namespaces),
647 )
648 } else {
649 String::new()
650 };
651 removals.push(EntityRemoval {
652 path: doc.path.clone(),
653 start: range.start,
654 end: range.end,
655 replacement,
656 });
657 }
658 blocks.push(entity_blocks.join("\n"));
659 }
660
661 let mut source_changes: BTreeMap<PathBuf, (String, String, Vec<Hunk>)> = BTreeMap::new();
662 let mut removals_by_path: BTreeMap<PathBuf, Vec<EntityRemoval>> = BTreeMap::new();
663 for removal in removals {
664 removals_by_path.entry(removal.path.clone()).or_default().push(removal);
665 }
666 for (path, mut path_removals) in removals_by_path {
667 path_removals.sort_by_key(|b| std::cmp::Reverse(b.start));
668 let original = source_texts.remove(&path).ok_or_else(|| {
669 RefactorError::Invalid(format!("missing source text for {}", path.display()))
670 })?;
671 let mut preview = original.clone();
672 let mut hunks = Vec::new();
673 for removal in path_removals {
674 let start = removal.start as usize;
675 let end = removal.end as usize;
676 let old_text = preview[start..end].to_string();
677 preview.replace_range(start..end, &removal.replacement);
678 hunks.push(Hunk {
679 start_byte: removal.start,
680 end_byte: removal.end,
681 old_text,
682 new_text: removal.replacement.clone(),
683 });
684 }
685 source_changes.insert(path, (original, preview, hunks));
686 }
687
688 let mut module_body = blocks.join("\n\n");
689 if !module_body.ends_with('\n') {
690 module_body.push('\n');
691 }
692 let prefix_header: String = prefix_lines.into_iter().collect::<Vec<_>>().join("\n");
693 let module_text = if prefix_header.is_empty() {
694 module_body
695 } else {
696 format!("{prefix_header}\n\n{module_body}")
697 };
698
699 let mut changes: Vec<FileChange> = source_changes
700 .into_iter()
701 .map(|(path, (original, preview, hunks))| FileChange {
702 path: path.clone(),
703 preview_text: preview,
704 original_text: original,
705 hunks,
706 })
707 .collect();
708
709 let output_original = if output_file.exists() {
710 read_source_text(output_file, document_overrides)?
711 } else {
712 String::new()
713 };
714 let output_preview = if output_original.is_empty() {
715 module_text.clone()
716 } else {
717 format!("{output_original}\n\n{module_text}")
718 };
719 changes.push(FileChange {
720 path: output_file.to_path_buf(),
721 preview_text: output_preview,
722 original_text: output_original,
723 hunks: vec![Hunk {
724 start_byte: 0,
725 end_byte: 0,
726 old_text: String::new(),
727 new_text: module_text,
728 }],
729 });
730
731 if leave_stub {
732 warnings.push("left deprecated stubs in source files".to_string());
733 }
734
735 Ok(RefactorPlan { changes, warnings })
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 #[test]
743 fn replace_prefix_uri_updates_at_prefix_uppercase() {
744 let text = "@PREFIX ex: <http://example.org/org#> .\nex:Person a owl:Class .\n";
745 let (out, hunks) =
746 replace_prefix_uri(text, "ex", "http://example.org/org#", "http://example.org/v2/org#");
747 assert!(out.contains("@PREFIX ex: <http://example.org/v2/org#>"));
748 assert!(!out.contains("@PREFIX ex: <http://example.org/org#>"));
749 assert!(!hunks.is_empty());
750 }
751
752 #[test]
753 fn replace_prefix_uri_updates_sparql_style_prefix() {
754 let text = "PREFIX ex: <http://example.org/org#>\nex:Person a owl:Class .\n";
755 let (out, _) =
756 replace_prefix_uri(text, "ex", "http://example.org/org#", "http://example.org/v2/org#");
757 assert!(out.contains("PREFIX ex: <http://example.org/v2/org#>"));
758 }
759
760 #[test]
761 fn owl_type_for_kind_maps_ontology() {
762 assert_eq!(owl_type_for_kind(EntityKind::Ontology), "owl:Ontology");
763 assert_eq!(owl_type_for_kind(EntityKind::Other), "owl:Class");
764 assert_eq!(owl_type_for_kind(EntityKind::Class), "owl:Class");
765 }
766
767 #[test]
768 fn escape_turtle_string_escapes_path_specials() {
769 assert_eq!(
770 escape_turtle_string(r#"C:\ontology\mod"ule.ttl"#),
771 r#"C:\\ontology\\mod\"ule.ttl"#
772 );
773 }
774}