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    let from_obo = catalog.find_entity(from_iri).and_then(|e| e.obo_id.clone());
28    let to_obo = catalog.find_entity(to_iri).and_then(|e| e.obo_id.clone()).or_else(|| {
29        // Derive local id from IRIs when renaming within the same OBO namespace.
30        short_obo_id_from_iri(to_iri).or_else(|| {
31            from_obo.as_ref().map(|_| {
32                short_obo_id_from_iri(to_iri).unwrap_or_else(|| {
33                    to_iri.rsplit(['#', '/']).next().unwrap_or(to_iri).to_string()
34                })
35            })
36        })
37    });
38
39    for doc in &catalog.data().documents {
40        if doc.parse_status != ParseStatus::Ok {
41            if text_contains_iri(doc, from_iri, document_overrides) {
42                warnings.push(format!("skipping errored file: {}", doc.path.display()));
43            }
44            continue;
45        }
46        let original = read_source_text(&doc.path, document_overrides)?;
47        let preview = match doc.format {
48            OntologyFormat::Turtle => {
49                if !original.contains(from_iri)
50                    && !contains_prefixed_ref(&original, from_iri, &doc.namespaces)
51                {
52                    continue;
53                }
54                let (mut preview_text, raw_hunks) =
55                    replace_iri_in_text(&original, from_iri, to_iri, &doc.namespaces);
56                let (swrl_text, swrl_hits) =
57                    ontocore_swrl::rewrite_swrl_iris_in_turtle(&preview_text, from_iri, to_iri);
58                if swrl_hits > 0 {
59                    preview_text = swrl_text;
60                    warnings.push(format!(
61                        "rewrote {swrl_hits} SWRL rule(s) in {}",
62                        doc.path.display()
63                    ));
64                }
65                if preview_text == original {
66                    continue;
67                }
68                let hunks: Vec<Hunk> = raw_hunks
69                    .into_iter()
70                    .map(|(start, end, old_text, new_text)| Hunk {
71                        start_byte: start as u64,
72                        end_byte: end as u64,
73                        old_text,
74                        new_text,
75                    })
76                    .collect();
77                file_changes.insert(
78                    doc.path.clone(),
79                    FileChange {
80                        path: doc.path.clone(),
81                        preview_text,
82                        original_text: original,
83                        hunks,
84                    },
85                );
86                continue;
87            }
88            OntologyFormat::Owl | OntologyFormat::RdfXml => {
89                if !original.contains(from_iri) {
90                    continue;
91                }
92                match ontocore_owl::remap_entity_iri_in_xml_text(
93                    &original,
94                    "rdfxml",
95                    from_iri,
96                    to_iri,
97                    &doc.namespaces,
98                ) {
99                    Ok(text) => text,
100                    Err(e) => {
101                        warnings.push(format!("skipping RDF/XML {}: {e}", doc.path.display()));
102                        continue;
103                    }
104                }
105            }
106            OntologyFormat::OwlXml => {
107                if !original.contains(from_iri) {
108                    continue;
109                }
110                match ontocore_owl::remap_entity_iri_in_xml_text(
111                    &original,
112                    "owlxml",
113                    from_iri,
114                    to_iri,
115                    &doc.namespaces,
116                ) {
117                    Ok(text) => text,
118                    Err(e) => {
119                        warnings.push(format!("skipping OWL/XML {}: {e}", doc.path.display()));
120                        continue;
121                    }
122                }
123            }
124            OntologyFormat::Obo => {
125                let from_id =
126                    from_obo.clone().or_else(|| short_obo_id_from_iri(from_iri)).unwrap_or_else(
127                        || from_iri.rsplit(['#', '/']).next().unwrap_or(from_iri).to_string(),
128                    );
129                let to_id =
130                    to_obo.clone().or_else(|| short_obo_id_from_iri(to_iri)).unwrap_or_else(|| {
131                        to_iri.rsplit(['#', '/']).next().unwrap_or(to_iri).to_string()
132                    });
133                if !original.contains(&from_id) {
134                    continue;
135                }
136                match ontocore_obo::remap_obo_id_in_text(&original, &from_id, &to_id) {
137                    Ok(text) => text,
138                    Err(e) => {
139                        warnings.push(format!("skipping OBO {}: {e}", doc.path.display()));
140                        continue;
141                    }
142                }
143            }
144            other => {
145                if text_contains_iri(doc, from_iri, document_overrides) {
146                    warnings.push(format!(
147                        "skipping unsupported format {} in {}",
148                        other.as_str(),
149                        doc.path.display()
150                    ));
151                }
152                continue;
153            }
154        };
155
156        if preview == original {
157            continue;
158        }
159        file_changes
160            .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview));
161    }
162
163    if file_changes.is_empty() {
164        warnings.push(format!("no files changed for IRI {from_iri}"));
165    }
166
167    Ok(RefactorPlan {
168        changes: file_changes.into_values().collect(),
169        warnings,
170        ..Default::default()
171    }
172    .with_metrics([from_iri, to_iri]))
173}
174
175fn short_obo_id_from_iri(iri: &str) -> Option<String> {
176    // Common OBO IRIs: http://purl.obolibrary.org/obo/EX_001 → EX:001
177    let local = iri.rsplit(['#', '/']).next()?;
178    if local.contains('_') && !local.contains(':') {
179        let (ns, id) = local.split_once('_')?;
180        return Some(format!("{ns}:{id}"));
181    }
182    if local.contains(':') {
183        return Some(local.to_string());
184    }
185    None
186}
187
188pub fn preview_merge_entities(
189    catalog: &OntologyCatalog,
190    keep_iri: &str,
191    merge_iri: &str,
192    document_overrides: &HashMap<PathBuf, String>,
193) -> Result<RefactorPlan> {
194    if keep_iri == merge_iri {
195        return Err(RefactorError::Invalid("keep and merge IRI must differ".to_string()));
196    }
197
198    let mut warnings = Vec::new();
199    if catalog.find_entity(keep_iri).is_none() {
200        warnings.push(format!("keep entity not found: {keep_iri}"));
201    }
202    if catalog.find_entity(merge_iri).is_none() {
203        warnings.push(format!("merge entity not found: {merge_iri}"));
204    }
205
206    let mut changes = BTreeMap::new();
207    for doc in &catalog.data().documents {
208        if doc.parse_status != ParseStatus::Ok {
209            if text_contains_iri(doc, merge_iri, document_overrides) {
210                warnings.push(format!("skipping errored file: {}", doc.path.display()));
211            }
212            continue;
213        }
214
215        let original = read_source_text(&doc.path, document_overrides)?;
216        let preview_text = match doc.format {
217            OntologyFormat::Turtle => {
218                if !original.contains(merge_iri)
219                    && !contains_prefixed_ref(&original, merge_iri, &doc.namespaces)
220                {
221                    continue;
222                }
223                let namespaces = ontocore_owl::namespaces_for_text(&original, &doc.namespaces);
224                let short = ontocore_owl::short_name_from_iri(merge_iri);
225                let mut declaration_ranges = ontocore_owl::all_entity_statement_ranges(
226                    &original,
227                    merge_iri,
228                    &short,
229                    &namespaces,
230                );
231                declaration_ranges.sort_by_key(|range| std::cmp::Reverse(range.start));
232
233                let mut without_merge_declaration = original.clone();
234                for range in declaration_ranges {
235                    without_merge_declaration
236                        .replace_range(range.start as usize..range.end as usize, "");
237                }
238                let (mut preview_text, _) = replace_iri_in_text(
239                    &without_merge_declaration,
240                    merge_iri,
241                    keep_iri,
242                    &doc.namespaces,
243                );
244                let (swrl_text, swrl_hits) =
245                    ontocore_swrl::rewrite_swrl_iris_in_turtle(&preview_text, merge_iri, keep_iri);
246                if swrl_hits > 0 {
247                    preview_text = swrl_text;
248                    warnings.push(format!(
249                        "rewrote {swrl_hits} SWRL rule(s) in {}",
250                        doc.path.display()
251                    ));
252                }
253                preview_text
254            }
255            OntologyFormat::Owl | OntologyFormat::RdfXml | OntologyFormat::OwlXml => {
256                if !original.contains(merge_iri) {
257                    continue;
258                }
259                let fmt = if doc.format == OntologyFormat::OwlXml { "owlxml" } else { "rdfxml" };
260                match ontocore_owl::remap_entity_iri_in_xml_text(
261                    &original,
262                    fmt,
263                    merge_iri,
264                    keep_iri,
265                    &doc.namespaces,
266                ) {
267                    Ok(text) => text,
268                    Err(e) => {
269                        warnings.push(format!("skipping {} {}: {e}", fmt, doc.path.display()));
270                        continue;
271                    }
272                }
273            }
274            OntologyFormat::Obo => {
275                let from_id = catalog
276                    .find_entity(merge_iri)
277                    .and_then(|e| e.obo_id.clone())
278                    .or_else(|| short_obo_id_from_iri(merge_iri))
279                    .unwrap_or_else(|| {
280                        merge_iri.rsplit(['#', '/']).next().unwrap_or(merge_iri).to_string()
281                    });
282                let to_id = catalog
283                    .find_entity(keep_iri)
284                    .and_then(|e| e.obo_id.clone())
285                    .or_else(|| short_obo_id_from_iri(keep_iri))
286                    .unwrap_or_else(|| {
287                        keep_iri.rsplit(['#', '/']).next().unwrap_or(keep_iri).to_string()
288                    });
289                if !original.contains(&from_id) {
290                    continue;
291                }
292                match ontocore_obo::remap_obo_id_in_text(&original, &from_id, &to_id) {
293                    Ok(text) => text,
294                    Err(e) => {
295                        warnings.push(format!("skipping OBO {}: {e}", doc.path.display()));
296                        continue;
297                    }
298                }
299            }
300            other => {
301                if text_contains_iri(doc, merge_iri, document_overrides) {
302                    warnings.push(format!(
303                        "skipping unsupported format {} in {}",
304                        other.as_str(),
305                        doc.path.display()
306                    ));
307                }
308                continue;
309            }
310        };
311
312        if preview_text == original {
313            continue;
314        }
315
316        changes
317            .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview_text));
318    }
319
320    if changes.is_empty() {
321        warnings.push(format!("no files changed for entity merge {merge_iri} -> {keep_iri}"));
322    }
323
324    Ok(RefactorPlan { changes: changes.into_values().collect(), warnings, ..Default::default() }
325        .with_metrics([keep_iri, merge_iri]))
326}
327
328pub fn preview_replace_entity(
329    catalog: &OntologyCatalog,
330    from_iri: &str,
331    to_iri: &str,
332    document_overrides: &HashMap<PathBuf, String>,
333) -> Result<RefactorPlan> {
334    if catalog.find_entity(to_iri).is_none() {
335        return preview_rename_iri(catalog, from_iri, to_iri, document_overrides);
336    }
337    if from_iri == to_iri {
338        return Err(RefactorError::Invalid("from and to IRI must differ".to_string()));
339    }
340    if catalog.find_entity(from_iri).is_none()
341        && find_usages_in_catalog(catalog, from_iri, document_overrides).is_empty()
342    {
343        return Err(RefactorError::EntityNotFound(from_iri.to_string()));
344    }
345
346    let mut changes = BTreeMap::new();
347    let mut warnings = Vec::new();
348    for doc in &catalog.data().documents {
349        if doc.parse_status != ParseStatus::Ok {
350            if text_contains_iri(doc, from_iri, document_overrides) {
351                warnings.push(format!("skipping errored file: {}", doc.path.display()));
352            }
353            continue;
354        }
355
356        let original = read_source_text(&doc.path, document_overrides)?;
357        let preview_text = match doc.format {
358            OntologyFormat::Turtle => {
359                if !original.contains(from_iri)
360                    && !contains_prefixed_ref(&original, from_iri, &doc.namespaces)
361                {
362                    continue;
363                }
364                let namespaces = ontocore_owl::namespaces_for_text(&original, &doc.namespaces);
365                let declaration_start =
366                    ontocore_owl::entity_primary_block_range(&original, from_iri, &namespaces)
367                        .map(|range| range.start as usize);
368                let mut preview_text = replace_iri_preserving_subject(
369                    &original,
370                    from_iri,
371                    to_iri,
372                    &doc.namespaces,
373                    declaration_start,
374                );
375                let (swrl_text, swrl_hits) =
376                    ontocore_swrl::rewrite_swrl_iris_in_turtle(&preview_text, from_iri, to_iri);
377                if swrl_hits > 0 {
378                    preview_text = swrl_text;
379                    warnings.push(format!(
380                        "rewrote {swrl_hits} SWRL rule(s) in {}",
381                        doc.path.display()
382                    ));
383                }
384                preview_text
385            }
386            OntologyFormat::Owl | OntologyFormat::RdfXml | OntologyFormat::OwlXml => {
387                if !original.contains(from_iri) {
388                    continue;
389                }
390                // Existing-target replace on XML remaps all mentions (incl. declaration).
391                warnings.push(format!(
392                    "non-Turtle replace rewrites all IRI mentions in {}",
393                    doc.path.display()
394                ));
395                let fmt = if doc.format == OntologyFormat::OwlXml { "owlxml" } else { "rdfxml" };
396                match ontocore_owl::remap_entity_iri_in_xml_text(
397                    &original,
398                    fmt,
399                    from_iri,
400                    to_iri,
401                    &doc.namespaces,
402                ) {
403                    Ok(text) => text,
404                    Err(e) => {
405                        warnings.push(format!("skipping {} {}: {e}", fmt, doc.path.display()));
406                        continue;
407                    }
408                }
409            }
410            OntologyFormat::Obo => {
411                let from_id = catalog
412                    .find_entity(from_iri)
413                    .and_then(|e| e.obo_id.clone())
414                    .or_else(|| short_obo_id_from_iri(from_iri))
415                    .unwrap_or_else(|| {
416                        from_iri.rsplit(['#', '/']).next().unwrap_or(from_iri).to_string()
417                    });
418                let to_id = catalog
419                    .find_entity(to_iri)
420                    .and_then(|e| e.obo_id.clone())
421                    .or_else(|| short_obo_id_from_iri(to_iri))
422                    .unwrap_or_else(|| {
423                        to_iri.rsplit(['#', '/']).next().unwrap_or(to_iri).to_string()
424                    });
425                if !original.contains(&from_id) {
426                    continue;
427                }
428                warnings.push(format!(
429                    "non-Turtle replace rewrites all IRI mentions in {}",
430                    doc.path.display()
431                ));
432                match ontocore_obo::remap_obo_id_in_text(&original, &from_id, &to_id) {
433                    Ok(text) => text,
434                    Err(e) => {
435                        warnings.push(format!("skipping OBO {}: {e}", doc.path.display()));
436                        continue;
437                    }
438                }
439            }
440            other => {
441                if text_contains_iri(doc, from_iri, document_overrides) {
442                    warnings.push(format!(
443                        "skipping unsupported format {} in {}",
444                        other.as_str(),
445                        doc.path.display()
446                    ));
447                }
448                continue;
449            }
450        };
451
452        if preview_text == original {
453            continue;
454        }
455
456        changes
457            .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview_text));
458    }
459
460    if changes.is_empty() {
461        warnings.push(format!("no files changed for entity replacement {from_iri} -> {to_iri}"));
462    }
463
464    Ok(RefactorPlan { changes: changes.into_values().collect(), warnings, ..Default::default() }
465        .with_metrics([from_iri, to_iri]))
466}
467
468fn replace_iri_preserving_subject(
469    text: &str,
470    from_iri: &str,
471    to_iri: &str,
472    namespaces: &BTreeMap<String, String>,
473    subject_start: Option<usize>,
474) -> String {
475    let Some(subject_start) = subject_start else {
476        return replace_iri_in_text(text, from_iri, to_iri, namespaces).0;
477    };
478    let subject_text = &text[subject_start..];
479    let subject_end = if subject_text.starts_with('<') {
480        subject_text.find('>').map(|offset| subject_start + offset + 1).unwrap_or(text.len())
481    } else {
482        subject_text
483            .find(|c: char| c.is_whitespace() || c == ';')
484            .map(|offset| subject_start + offset)
485            .unwrap_or(text.len())
486    };
487
488    let before = replace_iri_in_text(&text[..subject_start], from_iri, to_iri, namespaces).0;
489    let after = replace_iri_in_text(&text[subject_end..], from_iri, to_iri, namespaces).0;
490    format!("{before}{}{after}", &text[subject_start..subject_end])
491}
492
493fn whole_file_change(path: PathBuf, original_text: String, preview_text: String) -> FileChange {
494    FileChange {
495        path,
496        hunks: vec![Hunk {
497            start_byte: 0,
498            end_byte: original_text.len() as u64,
499            old_text: original_text.clone(),
500            new_text: preview_text.clone(),
501        }],
502        preview_text,
503        original_text,
504    }
505}
506
507pub fn preview_migrate_namespace(
508    catalog: &OntologyCatalog,
509    from_base: &str,
510    to_base: &str,
511    document_overrides: &HashMap<PathBuf, String>,
512) -> Result<RefactorPlan> {
513    let from = normalize_namespace_base(from_base);
514    let to = normalize_namespace_base(to_base);
515    if from == to {
516        return Err(RefactorError::Invalid("from and to namespace must differ".to_string()));
517    }
518
519    let all_iris: Vec<String> = catalog
520        .data()
521        .entities
522        .iter()
523        .filter(|e| remap_iri(&e.iri, &from, &to).is_some())
524        .map(|e| e.iri.clone())
525        .chain(catalog.data().axioms.iter().flat_map(|a| {
526            let mut v = Vec::new();
527            if remap_iri(&a.subject, &from, &to).is_some() {
528                v.push(a.subject.clone());
529            }
530            if remap_iri(&a.object, &from, &to).is_some() {
531                v.push(a.object.clone());
532            }
533            v
534        }))
535        .collect::<BTreeSet<_>>()
536        .into_iter()
537        .collect();
538
539    let mut changes: BTreeMap<PathBuf, FileChange> = BTreeMap::new();
540    let mut warnings = Vec::new();
541
542    for doc in &catalog.data().documents {
543        if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
544            continue;
545        }
546        let original = read_source_text(&doc.path, document_overrides)?;
547        let mut preview = original.clone();
548        let mut hunks = Vec::new();
549        let mut changed = false;
550
551        for old_iri in &all_iris {
552            let new_iri = remap_iri(old_iri, &from, &to).unwrap_or_else(|| old_iri.clone());
553            if !preview.contains(old_iri)
554                && !contains_prefixed_ref(&preview, old_iri, &doc.namespaces)
555            {
556                continue;
557            }
558            let (next, raw_hunks) =
559                replace_iri_in_text(&preview, old_iri, &new_iri, &doc.namespaces);
560            if next != preview {
561                preview = next;
562                changed = true;
563                hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
564                    start_byte: s as u64,
565                    end_byte: e as u64,
566                    old_text: o,
567                    new_text: n,
568                }));
569            }
570        }
571
572        for (prefix, ns) in &doc.namespaces {
573            if normalize_namespace_base(ns) == from {
574                let terminator = if ns.ends_with('/') { '/' } else { '#' };
575                let new_ns = format!("{to}{terminator}");
576                let (next, raw_hunks) = replace_prefix_uri(&preview, prefix, ns, &new_ns);
577                if next != preview {
578                    preview = next;
579                    changed = true;
580                    hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
581                        start_byte: s as u64,
582                        end_byte: e as u64,
583                        old_text: o,
584                        new_text: n,
585                    }));
586                }
587            }
588        }
589
590        if changed {
591            changes.insert(
592                doc.path.clone(),
593                FileChange {
594                    path: doc.path.clone(),
595                    preview_text: preview,
596                    original_text: original,
597                    hunks,
598                },
599            );
600        }
601    }
602
603    if changes.is_empty() {
604        warnings.push(format!("no Turtle files changed for namespace migration {from} -> {to}"));
605    }
606
607    Ok(RefactorPlan { changes: changes.into_values().collect(), warnings, ..Default::default() }
608        .with_metrics([from, to]))
609}
610
611fn is_prefix_declaration_line(line: &str) -> bool {
612    let keyword = line.split_whitespace().next().unwrap_or("");
613    keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")
614}
615
616fn prefix_declaration_name(line: &str) -> Option<&str> {
617    let mut parts = line.split_whitespace();
618    let keyword = parts.next()?;
619    if !(keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")) {
620        return None;
621    }
622    parts.next()?.strip_suffix(':')
623}
624
625fn replace_prefix_uri(
626    text: &str,
627    prefix: &str,
628    old_uri: &str,
629    new_uri: &str,
630) -> (String, Vec<(usize, usize, String, String)>) {
631    let old_term = format!("<{old_uri}>");
632    let new_term = format!("<{new_uri}>");
633    let mut out = String::with_capacity(text.len());
634    let mut hunks = Vec::new();
635    let mut offset = 0usize;
636    for line in text.split_inclusive('\n') {
637        let updated = if prefix_declaration_name(line) == Some(prefix) && line.contains(&old_term) {
638            line.replacen(&old_term, &new_term, 1)
639        } else {
640            line.to_string()
641        };
642        if updated != line {
643            hunks.push((offset, offset + line.len(), line.to_string(), updated.clone()));
644        }
645        out.push_str(&updated);
646        offset += line.len();
647    }
648    (out, hunks)
649}
650
651fn escape_turtle_string(value: &str) -> String {
652    let mut out = String::with_capacity(value.len());
653    for ch in value.chars() {
654        match ch {
655            '\\' => out.push_str("\\\\"),
656            '"' => out.push_str("\\\""),
657            '\n' => out.push_str("\\n"),
658            '\r' => out.push_str("\\r"),
659            '\t' => out.push_str("\\t"),
660            c => out.push(c),
661        }
662    }
663    out
664}
665
666fn find_usages_in_catalog(
667    catalog: &OntologyCatalog,
668    iri: &str,
669    document_overrides: &HashMap<PathBuf, String>,
670) -> Vec<()> {
671    let u = crate::usages::find_usages_with_overrides(catalog, iri, document_overrides);
672    vec![(); u.len()]
673}
674
675fn text_contains_iri(
676    doc: &ontocore_core::OntologyDocument,
677    iri: &str,
678    document_overrides: &HashMap<PathBuf, String>,
679) -> bool {
680    read_source_text(&doc.path, document_overrides).map(|t| t.contains(iri)).unwrap_or(false)
681}
682
683fn contains_prefixed_ref(text: &str, iri: &str, namespaces: &BTreeMap<String, String>) -> bool {
684    let short = ontocore_owl::short_name_from_iri(iri);
685    for (prefix, ns) in namespaces {
686        if iri.starts_with(ns) && text.contains(&format!("{prefix}:{short}")) {
687            return true;
688        }
689    }
690    false
691}
692
693fn prefixed_curie(iri: &str, namespaces: &BTreeMap<String, String>) -> String {
694    let short = ontocore_owl::short_name_from_iri(iri);
695    for (prefix, ns) in namespaces {
696        if iri.starts_with(ns) && !prefix.is_empty() {
697            return format!("{prefix}:{short}");
698        }
699    }
700    format!("<{iri}>")
701}
702
703fn owl_type_for_kind(kind: EntityKind) -> &'static str {
704    match kind {
705        EntityKind::Class => "owl:Class",
706        EntityKind::ObjectProperty => "owl:ObjectProperty",
707        EntityKind::DataProperty => "owl:DatatypeProperty",
708        EntityKind::AnnotationProperty => "owl:AnnotationProperty",
709        EntityKind::Individual => "owl:NamedIndividual",
710        EntityKind::Datatype => "rdfs:Datatype",
711        EntityKind::Ontology => "owl:Ontology",
712        EntityKind::Other => "owl:Class",
713    }
714}
715
716struct EntityRemoval {
717    path: PathBuf,
718    start: u64,
719    end: u64,
720    replacement: String,
721}
722
723/// Preview a refactor. Client-supplied paths (`target_file` / `output_file`) are jailed under
724/// `workspace_roots` **before** any filesystem read.
725pub fn preview_refactor(
726    catalog: &OntologyCatalog,
727    request: &crate::model::RefactorRequest,
728    document_overrides: &HashMap<PathBuf, String>,
729    workspace_roots: &[PathBuf],
730) -> Result<RefactorPlan> {
731    match request {
732        crate::model::RefactorRequest::RenameIri { from_iri, to_iri } => {
733            preview_rename_iri(catalog, from_iri, to_iri, document_overrides)
734        }
735        crate::model::RefactorRequest::MergeEntities { keep_iri, merge_iri } => {
736            preview_merge_entities(catalog, keep_iri, merge_iri, document_overrides)
737        }
738        crate::model::RefactorRequest::ReplaceEntity { from_iri, to_iri } => {
739            preview_replace_entity(catalog, from_iri, to_iri, document_overrides)
740        }
741        crate::model::RefactorRequest::MigrateNamespace { from_base, to_base } => {
742            preview_migrate_namespace(catalog, from_base, to_base, document_overrides)
743        }
744        crate::model::RefactorRequest::MoveEntity { entity_iri, target_file } => {
745            preview_move_entity(
746                catalog,
747                entity_iri,
748                target_file,
749                document_overrides,
750                workspace_roots,
751            )
752        }
753        crate::model::RefactorRequest::ExtractModule {
754            entity_iris,
755            output_file,
756            leave_stub,
757            locality,
758        } => preview_extract_module(
759            catalog,
760            entity_iris,
761            output_file,
762            *leave_stub,
763            *locality,
764            document_overrides,
765            workspace_roots,
766        ),
767        crate::model::RefactorRequest::MoveAxioms {
768            entity_iri,
769            target_file,
770            statement_indexes,
771            exclude_primary,
772        } => preview_move_axioms(
773            catalog,
774            entity_iri,
775            target_file,
776            statement_indexes,
777            *exclude_primary,
778            document_overrides,
779            workspace_roots,
780        ),
781        crate::model::RefactorRequest::MergeOntologies { source_paths, target_file } => {
782            crate::ontology::preview_merge_ontologies(
783                catalog,
784                source_paths,
785                target_file,
786                document_overrides,
787                workspace_roots,
788            )
789        }
790        crate::model::RefactorRequest::FlattenImports { ontology_file } => {
791            crate::ontology::preview_flatten_imports(
792                catalog,
793                ontology_file,
794                document_overrides,
795                workspace_roots,
796            )
797        }
798        crate::model::RefactorRequest::CleanupImports { ontology_file } => {
799            crate::ontology::preview_cleanup_imports(
800                catalog,
801                ontology_file,
802                document_overrides,
803                workspace_roots,
804            )
805        }
806    }
807}
808
809fn require_path_in_workspace(path: &Path, workspace_roots: &[PathBuf]) -> Result<()> {
810    validate_workspace_scope_any(path, workspace_roots).map_err(RefactorError::Invalid)?;
811    Ok(())
812}
813
814fn canonical_path(path: &Path) -> PathBuf {
815    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
816}
817
818pub fn preview_move_entity(
819    catalog: &OntologyCatalog,
820    entity_iri: &str,
821    target_file: &Path,
822    document_overrides: &HashMap<PathBuf, String>,
823    workspace_roots: &[PathBuf],
824) -> Result<RefactorPlan> {
825    // Jail before any filesystem read of the client-supplied path.
826    require_path_in_workspace(target_file, workspace_roots)?;
827    catalog
828        .find_entity(entity_iri)
829        .ok_or_else(|| RefactorError::EntityNotFound(entity_iri.to_string()))?;
830    let source_doc = catalog
831        .entity_document(entity_iri)
832        .ok_or_else(|| RefactorError::Invalid(format!("no document for {entity_iri}")))?;
833    if source_doc.format != OntologyFormat::Turtle {
834        return Err(RefactorError::UnsupportedFormat(source_doc.format.as_str().to_string()));
835    }
836
837    let source_canon = canonical_path(&source_doc.path);
838    let target_canon = canonical_path(target_file);
839    if source_canon == target_canon {
840        return Err(RefactorError::Invalid(
841            "target file must differ from source document".to_string(),
842        ));
843    }
844
845    let source_text = read_source_text(&source_doc.path, document_overrides)?;
846    let namespaces = ontocore_owl::namespaces_for_text(&source_text, &source_doc.namespaces);
847    let short = ontocore_owl::short_name_from_iri(entity_iri);
848    let mut ranges =
849        ontocore_owl::all_entity_statement_ranges(&source_text, entity_iri, &short, &namespaces);
850    if ranges.is_empty() {
851        return Err(RefactorError::Invalid(format!("entity block not found for {entity_iri}")));
852    }
853    ranges.sort_by_key(|r| r.start);
854
855    let mut block_parts = Vec::new();
856    let mut hunks = Vec::new();
857    for range in &ranges {
858        let part = source_text[range.start as usize..range.end as usize].to_string();
859        block_parts.push(part.clone());
860        hunks.push(Hunk {
861            start_byte: range.start,
862            end_byte: range.end,
863            old_text: part,
864            new_text: String::new(),
865        });
866    }
867    let block_text = block_parts.join("\n");
868    let mut source_without = source_text.clone();
869    for range in ranges.into_iter().rev() {
870        source_without.replace_range(range.start as usize..range.end as usize, "");
871    }
872
873    let mut prefix_lines = BTreeSet::new();
874    for line in source_text.lines() {
875        if is_prefix_declaration_line(line) {
876            prefix_lines.insert(line.trim().to_string());
877        }
878    }
879    let prefix_header: String = prefix_lines.iter().cloned().collect::<Vec<_>>().join("\n");
880    let block_with_prefixes = if prefix_header.is_empty() {
881        block_text.clone()
882    } else {
883        format!("{prefix_header}\n\n{block_text}")
884    };
885
886    let target_original = if target_file.exists() {
887        read_source_text(target_file, document_overrides)?
888    } else {
889        String::new()
890    };
891    let target_was_empty = target_original.is_empty();
892    let mut warnings = Vec::new();
893    let (target_preview, target_hunk_new) = if target_was_empty {
894        let mut out = block_with_prefixes.clone();
895        if !out.ends_with('\n') {
896            out.push('\n');
897        }
898        (out, block_with_prefixes)
899    } else {
900        // Merge missing source @prefix bindings into non-empty targets (#314).
901        let target_prefix_names: BTreeSet<String> = target_original
902            .lines()
903            .filter_map(prefix_declaration_name)
904            .map(str::to_string)
905            .collect();
906        let mut missing_prefixes = Vec::new();
907        for line in &prefix_lines {
908            let Some(name) = prefix_declaration_name(line) else {
909                continue;
910            };
911            if !target_prefix_names.contains(name) {
912                missing_prefixes.push(line.clone());
913            }
914        }
915        let inserted = if missing_prefixes.is_empty() {
916            String::new()
917        } else {
918            format!("{}\n\n", missing_prefixes.join("\n"))
919        };
920        if !missing_prefixes.is_empty() {
921            warnings.push(format!(
922                "added {} missing @prefix declaration(s) to {}",
923                missing_prefixes.len(),
924                target_file.display()
925            ));
926        }
927        let hunk_new = format!("{inserted}{block_text}");
928        (format!("{target_original}\n\n{hunk_new}"), hunk_new)
929    };
930
931    Ok(RefactorPlan {
932        changes: vec![
933            FileChange {
934                path: source_doc.path.clone(),
935                preview_text: source_without,
936                original_text: source_text,
937                hunks,
938            },
939            FileChange {
940                path: target_file.to_path_buf(),
941                preview_text: target_preview,
942                original_text: target_original,
943                hunks: vec![Hunk {
944                    start_byte: 0,
945                    end_byte: 0,
946                    old_text: String::new(),
947                    new_text: target_hunk_new,
948                }],
949            },
950        ],
951        warnings,
952        ..Default::default()
953    }
954    .with_metrics([entity_iri]))
955}
956
957/// Move selected subject statements for `entity_iri` into `target_file`.
958pub fn preview_move_axioms(
959    catalog: &OntologyCatalog,
960    entity_iri: &str,
961    target_file: &Path,
962    statement_indexes: &[usize],
963    exclude_primary: bool,
964    document_overrides: &HashMap<PathBuf, String>,
965    workspace_roots: &[PathBuf],
966) -> Result<RefactorPlan> {
967    require_path_in_workspace(target_file, workspace_roots)?;
968    catalog
969        .find_entity(entity_iri)
970        .ok_or_else(|| RefactorError::EntityNotFound(entity_iri.to_string()))?;
971    let Some(source_doc) = catalog.entity_document(entity_iri) else {
972        return Err(RefactorError::EntityNotFound(entity_iri.to_string()));
973    };
974    if source_doc.format != OntologyFormat::Turtle || source_doc.parse_status != ParseStatus::Ok {
975        return Err(RefactorError::Invalid(
976            "move axioms currently supports Turtle documents only".to_string(),
977        ));
978    }
979    let source_canon = canonical_path(&source_doc.path);
980    let target_canon = canonical_path(target_file);
981    if source_canon == target_canon {
982        return Err(RefactorError::Invalid(
983            "target file must differ from the source document".to_string(),
984        ));
985    }
986
987    let source_text = read_source_text(&source_doc.path, document_overrides)?;
988    let namespaces = ontocore_owl::namespaces_for_text(&source_text, &source_doc.namespaces);
989    let short = ontocore_owl::short_name_from_iri(entity_iri);
990    let ranges =
991        ontocore_owl::all_entity_statement_ranges(&source_text, entity_iri, &short, &namespaces);
992    if ranges.is_empty() {
993        return Err(RefactorError::Invalid(format!(
994            "no Turtle statements found for entity {entity_iri}"
995        )));
996    }
997
998    let primary = ontocore_owl::entity_primary_block_range(&source_text, entity_iri, &namespaces);
999    let selectable: Vec<(usize, ontocore_owl::ByteRange)> = ranges
1000        .into_iter()
1001        .enumerate()
1002        .filter(|(_, range)| {
1003            if !exclude_primary {
1004                return true;
1005            }
1006            primary.map(|p| p.start != range.start || p.end != range.end).unwrap_or(true)
1007        })
1008        .collect();
1009
1010    if selectable.is_empty() {
1011        return Err(RefactorError::Invalid(
1012            "no movable axiom statements (only primary declaration remains)".to_string(),
1013        ));
1014    }
1015
1016    let chosen: Vec<ontocore_owl::ByteRange> = if statement_indexes.is_empty() {
1017        selectable.into_iter().map(|(_, r)| r).collect()
1018    } else {
1019        let mut out = Vec::new();
1020        for idx in statement_indexes {
1021            let Some((_, range)) = selectable.iter().find(|(i, _)| *i == *idx) else {
1022                return Err(RefactorError::Invalid(format!(
1023                    "statement index {idx} out of range for entity {entity_iri}"
1024                )));
1025            };
1026            out.push(*range);
1027        }
1028        out
1029    };
1030
1031    let mut warnings = Vec::new();
1032    let mut blocks = Vec::new();
1033    let mut source_without = source_text.clone();
1034    let mut ordered = chosen;
1035    ordered.sort_by_key(|r| std::cmp::Reverse(r.start));
1036    for range in &ordered {
1037        let block = source_text[range.start as usize..range.end as usize].trim().to_string();
1038        if !block.is_empty() {
1039            blocks.push(block);
1040        }
1041        source_without.replace_range(range.start as usize..range.end as usize, "");
1042    }
1043    while source_without.contains("\n\n\n") {
1044        source_without = source_without.replace("\n\n\n", "\n\n");
1045    }
1046    blocks.reverse();
1047    let moved_text = blocks.join("\n\n");
1048    if moved_text.is_empty() {
1049        return Err(RefactorError::Invalid("selected statements produced empty move".to_string()));
1050    }
1051
1052    let target_original = if target_file.exists() {
1053        read_source_text(target_file, document_overrides)?
1054    } else {
1055        String::new()
1056    };
1057    let prefix_lines: Vec<String> = source_text
1058        .lines()
1059        .filter(|line| is_prefix_declaration_line(line))
1060        .map(str::to_string)
1061        .collect();
1062    let target_prefix_names: BTreeSet<String> =
1063        target_original.lines().filter_map(prefix_declaration_name).map(str::to_string).collect();
1064    let mut missing_prefixes = Vec::new();
1065    for line in &prefix_lines {
1066        let Some(name) = prefix_declaration_name(line) else {
1067            continue;
1068        };
1069        if !target_prefix_names.contains(name) {
1070            missing_prefixes.push(line.clone());
1071        }
1072    }
1073    let inserted = if missing_prefixes.is_empty() {
1074        String::new()
1075    } else {
1076        warnings.push(format!(
1077            "added {} missing @prefix declaration(s) to {}",
1078            missing_prefixes.len(),
1079            target_file.display()
1080        ));
1081        format!("{}\n\n", missing_prefixes.join("\n"))
1082    };
1083    let target_preview = if target_original.is_empty() {
1084        format!("{inserted}{moved_text}\n")
1085    } else {
1086        format!("{target_original}\n\n{inserted}{moved_text}\n")
1087    };
1088
1089    Ok(RefactorPlan {
1090        changes: vec![
1091            FileChange {
1092                path: source_doc.path.clone(),
1093                preview_text: source_without,
1094                original_text: source_text,
1095                hunks: vec![],
1096            },
1097            FileChange {
1098                path: target_file.to_path_buf(),
1099                preview_text: target_preview.clone(),
1100                original_text: target_original,
1101                hunks: vec![Hunk {
1102                    start_byte: 0,
1103                    end_byte: 0,
1104                    old_text: String::new(),
1105                    new_text: format!("{inserted}{moved_text}"),
1106                }],
1107            },
1108        ],
1109        warnings,
1110        ..Default::default()
1111    }
1112    .with_metrics([entity_iri]))
1113}
1114
1115pub fn preview_extract_module(
1116    catalog: &OntologyCatalog,
1117    entity_iris: &[String],
1118    output_file: &Path,
1119    leave_stub: bool,
1120    locality: bool,
1121    document_overrides: &HashMap<PathBuf, String>,
1122    workspace_roots: &[PathBuf],
1123) -> Result<RefactorPlan> {
1124    // Jail before any filesystem read of the client-supplied path.
1125    require_path_in_workspace(output_file, workspace_roots)?;
1126    if entity_iris.is_empty() {
1127        return Err(RefactorError::Invalid("no entities selected".to_string()));
1128    }
1129
1130    let expanded: Vec<String> = if locality {
1131        crate::ontology::expand_signature_locality(catalog, entity_iris)
1132    } else {
1133        entity_iris.to_vec()
1134    };
1135    let entity_iris = &expanded;
1136
1137    let mut blocks = Vec::new();
1138    let mut removals: Vec<EntityRemoval> = Vec::new();
1139    let mut source_texts: BTreeMap<PathBuf, String> = BTreeMap::new();
1140    let mut prefix_lines = BTreeSet::new();
1141    let mut warnings = Vec::new();
1142    if locality {
1143        warnings.push(format!(
1144            "locality expansion selected {} entit{}",
1145            entity_iris.len(),
1146            if entity_iris.len() == 1 { "y" } else { "ies" }
1147        ));
1148    }
1149
1150    for iri in entity_iris {
1151        let entity =
1152            catalog.find_entity(iri).ok_or_else(|| RefactorError::EntityNotFound(iri.clone()))?;
1153        let doc = catalog
1154            .entity_document(iri)
1155            .ok_or_else(|| RefactorError::Invalid(format!("no document for {iri}")))?;
1156        if doc.format != OntologyFormat::Turtle {
1157            return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
1158        }
1159        let text = if let Some(existing) = source_texts.get(&doc.path) {
1160            existing.clone()
1161        } else {
1162            read_source_text(&doc.path, document_overrides)?
1163        };
1164        source_texts.insert(doc.path.clone(), text.clone());
1165        for line in text.lines() {
1166            if is_prefix_declaration_line(line) {
1167                prefix_lines.insert(line.trim().to_string());
1168            }
1169        }
1170        let namespaces = ontocore_owl::namespaces_for_text(&text, &doc.namespaces);
1171        let short = ontocore_owl::short_name_from_iri(iri);
1172        let mut ranges = ontocore_owl::all_entity_statement_ranges(&text, iri, &short, &namespaces);
1173        if ranges.is_empty() {
1174            return Err(RefactorError::Invalid(format!("block not found for {iri}")));
1175        }
1176        ranges.sort_by_key(|r| r.start);
1177        let mut entity_blocks = Vec::new();
1178        for (idx, range) in ranges.iter().enumerate() {
1179            let block = text[range.start as usize..range.end as usize].to_string();
1180            entity_blocks.push(block);
1181            let replacement = if leave_stub && idx == 0 {
1182                let owl_type = owl_type_for_kind(entity.kind);
1183                let moved_path = escape_turtle_string(&output_file.display().to_string());
1184                format!(
1185                    "{} a {owl_type} ;\n    owl:deprecated true ;\n    rdfs:comment \"Moved to {moved_path}\" .\n",
1186                    prefixed_curie(iri, &namespaces),
1187                )
1188            } else {
1189                String::new()
1190            };
1191            removals.push(EntityRemoval {
1192                path: doc.path.clone(),
1193                start: range.start,
1194                end: range.end,
1195                replacement,
1196            });
1197        }
1198        blocks.push(entity_blocks.join("\n"));
1199    }
1200
1201    let mut source_changes: BTreeMap<PathBuf, (String, String, Vec<Hunk>)> = BTreeMap::new();
1202    let mut removals_by_path: BTreeMap<PathBuf, Vec<EntityRemoval>> = BTreeMap::new();
1203    for removal in removals {
1204        removals_by_path.entry(removal.path.clone()).or_default().push(removal);
1205    }
1206    for (path, mut path_removals) in removals_by_path {
1207        path_removals.sort_by_key(|b| std::cmp::Reverse(b.start));
1208        let original = source_texts.remove(&path).ok_or_else(|| {
1209            RefactorError::Invalid(format!("missing source text for {}", path.display()))
1210        })?;
1211        let mut preview = original.clone();
1212        let mut hunks = Vec::new();
1213        for removal in path_removals {
1214            let start = removal.start as usize;
1215            let end = removal.end as usize;
1216            let old_text = preview[start..end].to_string();
1217            preview.replace_range(start..end, &removal.replacement);
1218            hunks.push(Hunk {
1219                start_byte: removal.start,
1220                end_byte: removal.end,
1221                old_text,
1222                new_text: removal.replacement.clone(),
1223            });
1224        }
1225        source_changes.insert(path, (original, preview, hunks));
1226    }
1227
1228    let mut module_body = blocks.join("\n\n");
1229    if !module_body.ends_with('\n') {
1230        module_body.push('\n');
1231    }
1232    let prefix_header: String = prefix_lines.into_iter().collect::<Vec<_>>().join("\n");
1233    let module_text = if prefix_header.is_empty() {
1234        module_body
1235    } else {
1236        format!("{prefix_header}\n\n{module_body}")
1237    };
1238
1239    let mut changes: Vec<FileChange> = source_changes
1240        .into_iter()
1241        .map(|(path, (original, preview, hunks))| FileChange {
1242            path: path.clone(),
1243            preview_text: preview,
1244            original_text: original,
1245            hunks,
1246        })
1247        .collect();
1248
1249    let output_original = if output_file.exists() {
1250        read_source_text(output_file, document_overrides)?
1251    } else {
1252        String::new()
1253    };
1254    let output_preview = if output_original.is_empty() {
1255        module_text.clone()
1256    } else {
1257        format!("{output_original}\n\n{module_text}")
1258    };
1259    changes.push(FileChange {
1260        path: output_file.to_path_buf(),
1261        preview_text: output_preview,
1262        original_text: output_original,
1263        hunks: vec![Hunk {
1264            start_byte: 0,
1265            end_byte: 0,
1266            old_text: String::new(),
1267            new_text: module_text,
1268        }],
1269    });
1270
1271    if leave_stub {
1272        warnings.push("left deprecated stubs in source files".to_string());
1273    }
1274
1275    Ok(RefactorPlan { changes, warnings, ..Default::default() }
1276        .with_metrics(entity_iris.iter().map(|s| s.as_str())))
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281    use super::*;
1282
1283    #[test]
1284    fn replace_prefix_uri_updates_at_prefix_uppercase() {
1285        let text = "@PREFIX ex: <http://example.org/org#> .\nex:Person a owl:Class .\n";
1286        let (out, hunks) =
1287            replace_prefix_uri(text, "ex", "http://example.org/org#", "http://example.org/v2/org#");
1288        assert!(out.contains("@PREFIX ex: <http://example.org/v2/org#>"));
1289        assert!(!out.contains("@PREFIX ex: <http://example.org/org#>"));
1290        assert!(!hunks.is_empty());
1291    }
1292
1293    #[test]
1294    fn replace_prefix_uri_updates_sparql_style_prefix() {
1295        let text = "PREFIX ex: <http://example.org/org#>\nex:Person a owl:Class .\n";
1296        let (out, _) =
1297            replace_prefix_uri(text, "ex", "http://example.org/org#", "http://example.org/v2/org#");
1298        assert!(out.contains("PREFIX ex: <http://example.org/v2/org#>"));
1299    }
1300
1301    #[test]
1302    fn owl_type_for_kind_maps_ontology() {
1303        assert_eq!(owl_type_for_kind(EntityKind::Ontology), "owl:Ontology");
1304        assert_eq!(owl_type_for_kind(EntityKind::Other), "owl:Class");
1305        assert_eq!(owl_type_for_kind(EntityKind::Class), "owl:Class");
1306    }
1307
1308    #[test]
1309    fn escape_turtle_string_escapes_path_specials() {
1310        assert_eq!(
1311            escape_turtle_string(r#"C:\ontology\mod"ule.ttl"#),
1312            r#"C:\\ontology\\mod\"ule.ttl"#
1313        );
1314    }
1315}