Skip to main content

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