Skip to main content

ontocore_refactor/
rename.rs

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
449/// Preview a refactor. Client-supplied paths (`target_file` / `output_file`) are jailed under
450/// `workspace_roots` **before** any filesystem read.
451pub 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    // Jail before any filesystem read of the client-supplied path.
509    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        // Merge missing source @prefix bindings into non-empty targets (#314).
584        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    // Jail before any filesystem read of the client-supplied path.
647    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}