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_migrate_namespace(
70 catalog: &OntologyCatalog,
71 from_base: &str,
72 to_base: &str,
73 document_overrides: &HashMap<PathBuf, String>,
74) -> Result<RefactorPlan> {
75 let from = normalize_namespace_base(from_base);
76 let to = normalize_namespace_base(to_base);
77 if from == to {
78 return Err(RefactorError::Invalid("from and to namespace must differ".to_string()));
79 }
80
81 let all_iris: Vec<String> = catalog
82 .data()
83 .entities
84 .iter()
85 .filter(|e| remap_iri(&e.iri, &from, &to).is_some())
86 .map(|e| e.iri.clone())
87 .chain(catalog.data().axioms.iter().flat_map(|a| {
88 let mut v = Vec::new();
89 if remap_iri(&a.subject, &from, &to).is_some() {
90 v.push(a.subject.clone());
91 }
92 if remap_iri(&a.object, &from, &to).is_some() {
93 v.push(a.object.clone());
94 }
95 v
96 }))
97 .collect::<BTreeSet<_>>()
98 .into_iter()
99 .collect();
100
101 let mut changes: BTreeMap<PathBuf, FileChange> = BTreeMap::new();
102 let mut warnings = Vec::new();
103
104 for doc in &catalog.data().documents {
105 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
106 continue;
107 }
108 let original = read_source_text(&doc.path, document_overrides)?;
109 let mut preview = original.clone();
110 let mut hunks = Vec::new();
111 let mut changed = false;
112
113 for old_iri in &all_iris {
114 let new_iri = remap_iri(old_iri, &from, &to).unwrap_or_else(|| old_iri.clone());
115 if !preview.contains(old_iri)
116 && !contains_prefixed_ref(&preview, old_iri, &doc.namespaces)
117 {
118 continue;
119 }
120 let (next, raw_hunks) =
121 replace_iri_in_text(&preview, old_iri, &new_iri, &doc.namespaces);
122 if next != preview {
123 preview = next;
124 changed = true;
125 hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
126 start_byte: s as u64,
127 end_byte: e as u64,
128 old_text: o,
129 new_text: n,
130 }));
131 }
132 }
133
134 for (prefix, ns) in &doc.namespaces {
135 if normalize_namespace_base(ns) == from {
136 let terminator = if ns.ends_with('/') { '/' } else { '#' };
137 let new_ns = format!("{to}{terminator}");
138 let (next, raw_hunks) = replace_prefix_uri(&preview, prefix, ns, &new_ns);
139 if next != preview {
140 preview = next;
141 changed = true;
142 hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
143 start_byte: s as u64,
144 end_byte: e as u64,
145 old_text: o,
146 new_text: n,
147 }));
148 }
149 }
150 }
151
152 if changed {
153 changes.insert(
154 doc.path.clone(),
155 FileChange {
156 path: doc.path.clone(),
157 preview_text: preview,
158 original_text: original,
159 hunks,
160 },
161 );
162 }
163 }
164
165 if changes.is_empty() {
166 warnings.push(format!("no Turtle files changed for namespace migration {from} -> {to}"));
167 }
168
169 Ok(RefactorPlan { changes: changes.into_values().collect(), warnings })
170}
171
172fn replace_prefix_uri(
173 text: &str,
174 prefix: &str,
175 old_uri: &str,
176 new_uri: &str,
177) -> (String, Vec<(usize, usize, String, String)>) {
178 let old_decl = format!("@prefix {prefix}: <{old_uri}>");
179 let new_decl = format!("@prefix {prefix}: <{new_uri}>");
180 let result = text.replacements(old_decl.as_str(), new_decl.as_str());
181 let mut hunks = Vec::new();
182 if result != text {
183 if let Some(pos) = text.find(&old_decl) {
184 hunks.push((pos, pos + old_decl.len(), old_decl, new_decl.clone()));
185 }
186 }
187 (result, hunks)
188}
189
190trait Replacements {
191 fn replacements(&self, from: &str, to: &str) -> String;
192}
193
194impl Replacements for str {
195 fn replacements(&self, from: &str, to: &str) -> String {
196 self.replace(from, to)
197 }
198}
199
200fn find_usages_in_catalog(
201 catalog: &OntologyCatalog,
202 iri: &str,
203 document_overrides: &HashMap<PathBuf, String>,
204) -> Vec<()> {
205 let u = crate::usages::find_usages_with_overrides(catalog, iri, document_overrides);
206 vec![(); u.len()]
207}
208
209fn text_contains_iri(
210 doc: &ontocore_core::OntologyDocument,
211 iri: &str,
212 document_overrides: &HashMap<PathBuf, String>,
213) -> bool {
214 read_source_text(&doc.path, document_overrides).map(|t| t.contains(iri)).unwrap_or(false)
215}
216
217fn contains_prefixed_ref(text: &str, iri: &str, namespaces: &BTreeMap<String, String>) -> bool {
218 let short = ontocore_owl::short_name_from_iri(iri);
219 for (prefix, ns) in namespaces {
220 if iri.starts_with(ns) && text.contains(&format!("{prefix}:{short}")) {
221 return true;
222 }
223 }
224 false
225}
226
227fn prefixed_curie(iri: &str, namespaces: &BTreeMap<String, String>) -> String {
228 let short = ontocore_owl::short_name_from_iri(iri);
229 for (prefix, ns) in namespaces {
230 if iri.starts_with(ns) && !prefix.is_empty() {
231 return format!("{prefix}:{short}");
232 }
233 }
234 format!("<{iri}>")
235}
236
237fn owl_type_for_kind(kind: EntityKind) -> &'static str {
238 match kind {
239 EntityKind::Class => "owl:Class",
240 EntityKind::ObjectProperty => "owl:ObjectProperty",
241 EntityKind::DataProperty => "owl:DatatypeProperty",
242 EntityKind::AnnotationProperty => "owl:AnnotationProperty",
243 EntityKind::Individual => "owl:NamedIndividual",
244 EntityKind::Ontology | EntityKind::Other => "owl:Class",
245 }
246}
247
248struct EntityRemoval {
249 path: PathBuf,
250 start: u64,
251 end: u64,
252 replacement: String,
253}
254
255pub fn preview_refactor(
258 catalog: &OntologyCatalog,
259 request: &crate::model::RefactorRequest,
260 document_overrides: &HashMap<PathBuf, String>,
261 workspace_roots: &[PathBuf],
262) -> Result<RefactorPlan> {
263 match request {
264 crate::model::RefactorRequest::RenameIri { from_iri, to_iri } => {
265 preview_rename_iri(catalog, from_iri, to_iri, document_overrides)
266 }
267 crate::model::RefactorRequest::MigrateNamespace { from_base, to_base } => {
268 preview_migrate_namespace(catalog, from_base, to_base, document_overrides)
269 }
270 crate::model::RefactorRequest::MoveEntity { entity_iri, target_file } => {
271 preview_move_entity(
272 catalog,
273 entity_iri,
274 target_file,
275 document_overrides,
276 workspace_roots,
277 )
278 }
279 crate::model::RefactorRequest::ExtractModule { entity_iris, output_file, leave_stub } => {
280 preview_extract_module(
281 catalog,
282 entity_iris,
283 output_file,
284 *leave_stub,
285 document_overrides,
286 workspace_roots,
287 )
288 }
289 }
290}
291
292fn require_path_in_workspace(path: &Path, workspace_roots: &[PathBuf]) -> Result<()> {
293 validate_workspace_scope_any(path, workspace_roots).map_err(RefactorError::Invalid)?;
294 Ok(())
295}
296
297fn canonical_path(path: &Path) -> PathBuf {
298 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
299}
300
301pub fn preview_move_entity(
302 catalog: &OntologyCatalog,
303 entity_iri: &str,
304 target_file: &Path,
305 document_overrides: &HashMap<PathBuf, String>,
306 workspace_roots: &[PathBuf],
307) -> Result<RefactorPlan> {
308 require_path_in_workspace(target_file, workspace_roots)?;
310 catalog
311 .find_entity(entity_iri)
312 .ok_or_else(|| RefactorError::EntityNotFound(entity_iri.to_string()))?;
313 let source_doc = catalog
314 .entity_document(entity_iri)
315 .ok_or_else(|| RefactorError::Invalid(format!("no document for {entity_iri}")))?;
316 if source_doc.format != OntologyFormat::Turtle {
317 return Err(RefactorError::UnsupportedFormat(source_doc.format.as_str().to_string()));
318 }
319
320 let source_canon = canonical_path(&source_doc.path);
321 let target_canon = canonical_path(target_file);
322 if source_canon == target_canon {
323 return Err(RefactorError::Invalid(
324 "target file must differ from source document".to_string(),
325 ));
326 }
327
328 let source_text = read_source_text(&source_doc.path, document_overrides)?;
329 let namespaces = ontocore_owl::namespaces_for_text(&source_text, &source_doc.namespaces);
330 let short = ontocore_owl::short_name_from_iri(entity_iri);
331 let mut ranges =
332 ontocore_owl::all_entity_statement_ranges(&source_text, entity_iri, &short, &namespaces);
333 if ranges.is_empty() {
334 return Err(RefactorError::Invalid(format!("entity block not found for {entity_iri}")));
335 }
336 ranges.sort_by_key(|r| r.start);
337
338 let mut block_parts = Vec::new();
339 let mut hunks = Vec::new();
340 for range in &ranges {
341 let part = source_text[range.start as usize..range.end as usize].to_string();
342 block_parts.push(part.clone());
343 hunks.push(Hunk {
344 start_byte: range.start,
345 end_byte: range.end,
346 old_text: part,
347 new_text: String::new(),
348 });
349 }
350 let block_text = block_parts.join("\n");
351 let mut source_without = source_text.clone();
352 for range in ranges.into_iter().rev() {
353 source_without.replace_range(range.start as usize..range.end as usize, "");
354 }
355
356 let target_original = if target_file.exists() {
357 read_source_text(target_file, document_overrides)?
358 } else {
359 String::new()
360 };
361 let target_preview = if target_original.is_empty() {
362 format!("{block_text}\n")
363 } else {
364 format!("{target_original}\n\n{block_text}")
365 };
366
367 Ok(RefactorPlan {
368 changes: vec![
369 FileChange {
370 path: source_doc.path.clone(),
371 preview_text: source_without,
372 original_text: source_text,
373 hunks,
374 },
375 FileChange {
376 path: target_file.to_path_buf(),
377 preview_text: target_preview,
378 original_text: target_original,
379 hunks: vec![Hunk {
380 start_byte: 0,
381 end_byte: 0,
382 old_text: String::new(),
383 new_text: block_text,
384 }],
385 },
386 ],
387 warnings: Vec::new(),
388 })
389}
390
391pub fn preview_extract_module(
392 catalog: &OntologyCatalog,
393 entity_iris: &[String],
394 output_file: &Path,
395 leave_stub: bool,
396 document_overrides: &HashMap<PathBuf, String>,
397 workspace_roots: &[PathBuf],
398) -> Result<RefactorPlan> {
399 require_path_in_workspace(output_file, workspace_roots)?;
401 if entity_iris.is_empty() {
402 return Err(RefactorError::Invalid("no entities selected".to_string()));
403 }
404
405 let mut blocks = Vec::new();
406 let mut removals: Vec<EntityRemoval> = Vec::new();
407 let mut source_texts: BTreeMap<PathBuf, String> = BTreeMap::new();
408 let mut prefix_lines = BTreeSet::new();
409 let mut warnings = Vec::new();
410
411 for iri in entity_iris {
412 let entity =
413 catalog.find_entity(iri).ok_or_else(|| RefactorError::EntityNotFound(iri.clone()))?;
414 let doc = catalog
415 .entity_document(iri)
416 .ok_or_else(|| RefactorError::Invalid(format!("no document for {iri}")))?;
417 if doc.format != OntologyFormat::Turtle {
418 return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
419 }
420 let text = if let Some(existing) = source_texts.get(&doc.path) {
421 existing.clone()
422 } else {
423 read_source_text(&doc.path, document_overrides)?
424 };
425 source_texts.insert(doc.path.clone(), text.clone());
426 for line in text.lines() {
427 if line.trim_start().starts_with("@prefix") {
428 prefix_lines.insert(line.trim().to_string());
429 }
430 }
431 let namespaces = ontocore_owl::namespaces_for_text(&text, &doc.namespaces);
432 let short = ontocore_owl::short_name_from_iri(iri);
433 let mut ranges = ontocore_owl::all_entity_statement_ranges(&text, iri, &short, &namespaces);
434 if ranges.is_empty() {
435 return Err(RefactorError::Invalid(format!("block not found for {iri}")));
436 }
437 ranges.sort_by_key(|r| r.start);
438 let mut entity_blocks = Vec::new();
439 for (idx, range) in ranges.iter().enumerate() {
440 let block = text[range.start as usize..range.end as usize].to_string();
441 entity_blocks.push(block);
442 let replacement = if leave_stub && idx == 0 {
443 let owl_type = owl_type_for_kind(entity.kind);
444 format!(
445 "{} a {owl_type} ;\n owl:deprecated true ;\n rdfs:comment \"Moved to {}\" .\n",
446 prefixed_curie(iri, &namespaces),
447 output_file.display()
448 )
449 } else {
450 String::new()
451 };
452 removals.push(EntityRemoval {
453 path: doc.path.clone(),
454 start: range.start,
455 end: range.end,
456 replacement,
457 });
458 }
459 blocks.push(entity_blocks.join("\n"));
460 }
461
462 let mut source_changes: BTreeMap<PathBuf, (String, String, Vec<Hunk>)> = BTreeMap::new();
463 let mut removals_by_path: BTreeMap<PathBuf, Vec<EntityRemoval>> = BTreeMap::new();
464 for removal in removals {
465 removals_by_path.entry(removal.path.clone()).or_default().push(removal);
466 }
467 for (path, mut path_removals) in removals_by_path {
468 path_removals.sort_by_key(|b| std::cmp::Reverse(b.start));
469 let original = source_texts.remove(&path).ok_or_else(|| {
470 RefactorError::Invalid(format!("missing source text for {}", path.display()))
471 })?;
472 let mut preview = original.clone();
473 let mut hunks = Vec::new();
474 for removal in path_removals {
475 let start = removal.start as usize;
476 let end = removal.end as usize;
477 let old_text = preview[start..end].to_string();
478 preview.replace_range(start..end, &removal.replacement);
479 hunks.push(Hunk {
480 start_byte: removal.start,
481 end_byte: removal.end,
482 old_text,
483 new_text: removal.replacement.clone(),
484 });
485 }
486 source_changes.insert(path, (original, preview, hunks));
487 }
488
489 let mut module_body = blocks.join("\n\n");
490 if !module_body.ends_with('\n') {
491 module_body.push('\n');
492 }
493 let prefix_header: String = prefix_lines.into_iter().collect::<Vec<_>>().join("\n");
494 let module_text = if prefix_header.is_empty() {
495 module_body
496 } else {
497 format!("{prefix_header}\n\n{module_body}")
498 };
499
500 let mut changes: Vec<FileChange> = source_changes
501 .into_iter()
502 .map(|(path, (original, preview, hunks))| FileChange {
503 path: path.clone(),
504 preview_text: preview,
505 original_text: original,
506 hunks,
507 })
508 .collect();
509
510 let output_original = if output_file.exists() {
511 read_source_text(output_file, document_overrides)?
512 } else {
513 String::new()
514 };
515 let output_preview = if output_original.is_empty() {
516 module_text.clone()
517 } else {
518 format!("{output_original}\n\n{module_text}")
519 };
520 changes.push(FileChange {
521 path: output_file.to_path_buf(),
522 preview_text: output_preview,
523 original_text: output_original,
524 hunks: vec![Hunk {
525 start_byte: 0,
526 end_byte: 0,
527 old_text: String::new(),
528 new_text: module_text,
529 }],
530 });
531
532 if leave_stub {
533 warnings.push("left deprecated stubs in source files".to_string());
534 }
535
536 Ok(RefactorPlan { changes, warnings })
537}