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::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
448/// Preview a refactor. Client-supplied paths (`target_file` / `output_file`) are jailed under
449/// `workspace_roots` **before** any filesystem read.
450pub 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    // Jail before any filesystem read of the client-supplied path.
508    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    // Jail before any filesystem read of the client-supplied path.
599    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}