Skip to main content

strixonomy_refactor/
ontology.rs

1//! Ontology-level Turtle refactor operations (merge, flatten/cleanup imports).
2//!
3//! # Locality-based module extraction
4//!
5//! When [`crate::preview_extract_module`] is called with `locality = true`, the seed
6//! signature is closed under a **bottom-locality MVP heuristic** before signature-copy
7//! extraction:
8//!
9//! 1. Start with Σ = the requested entity IRIs.
10//! 2. Walk catalog axioms; for each axiom α let `sig(α)` be its named entities
11//!    (subject, object, and non-builtin predicate), excluding RDF/RDFS/OWL/XSD IRIs.
12//! 3. If `sig(α) ⊆ Σ`, treat α as bottom-local and (redundantly) keep Σ unchanged.
13//! 4. If `sig(α) ∩ Σ ≠ ∅`, select α and expand `Σ := Σ ∪ sig(α)`.
14//! 5. Repeat until Σ is quiet (fixed-point).
15//! 6. Extract Turtle statement blocks for every entity remaining in Σ (same move
16//!    semantics as signature-copy extract).
17//!
18//! Steps 3–4 together grow a connected module around the seed; axioms already wholly
19//! inside Σ stay available for copy via entity subject blocks.
20
21use crate::error::{RefactorError, Result};
22use crate::model::{FileChange, Hunk, RefactorPlan};
23use crate::source::read_source_text;
24use std::collections::{BTreeMap, BTreeSet, HashMap};
25use std::path::{Path, PathBuf};
26use strixonomy_catalog::OntologyCatalog;
27use strixonomy_core::{
28    document_lookup::{document_matches_ontology_id, normalize_iri},
29    OntologyFormat, ParseStatus,
30};
31
32/// Expand seed entity IRIs under the bottom-locality MVP heuristic (see module docs).
33pub fn expand_signature_locality(catalog: &OntologyCatalog, seeds: &[String]) -> Vec<String> {
34    let mut sigma: BTreeSet<String> = seeds.iter().cloned().collect();
35    let known: BTreeSet<&str> = catalog.data().entities.iter().map(|e| e.iri.as_str()).collect();
36
37    let mut changed = true;
38    while changed {
39        changed = false;
40        for axiom in &catalog.data().axioms {
41            let sig = axiom_named_signature(axiom, &known);
42            if sig.is_empty() {
43                continue;
44            }
45            let intersects = sig.iter().any(|iri| sigma.contains(iri));
46            let subset = sig.iter().all(|iri| sigma.contains(iri));
47            if subset {
48                // Bottom-local wrt Σ — already covered; no expansion needed.
49                continue;
50            }
51            if intersects {
52                for iri in sig {
53                    // Only pull catalog-backed entities into the extract set.
54                    if !known.contains(iri.as_str()) {
55                        continue;
56                    }
57                    if sigma.insert(iri) {
58                        changed = true;
59                    }
60                }
61            }
62        }
63    }
64    sigma.into_iter().collect()
65}
66
67fn axiom_named_signature(
68    axiom: &strixonomy_core::Axiom,
69    known: &BTreeSet<&str>,
70) -> BTreeSet<String> {
71    let mut out = BTreeSet::new();
72    for candidate in [&axiom.subject, &axiom.object, &axiom.predicate] {
73        if is_builtin_iri(candidate) {
74            continue;
75        }
76        // Prefer catalog entities; still accept absolute http(s) IRIs for external parents.
77        if known.contains(candidate.as_str()) || looks_like_absolute_iri(candidate) {
78            out.insert(candidate.clone());
79        }
80    }
81    out
82}
83
84fn looks_like_absolute_iri(s: &str) -> bool {
85    s.starts_with("http://") || s.starts_with("https://") || s.starts_with("urn:")
86}
87
88fn is_builtin_iri(iri: &str) -> bool {
89    iri.starts_with("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
90        || iri.starts_with("http://www.w3.org/2000/01/rdf-schema#")
91        || iri.starts_with("http://www.w3.org/2002/07/owl#")
92        || iri.starts_with("http://www.w3.org/2001/XMLSchema#")
93        || iri == "http://www.w3.org/2002/07/owl#Thing"
94        || iri == "http://www.w3.org/2002/07/owl#Nothing"
95}
96
97fn require_path_in_workspace(path: &Path, workspace_roots: &[PathBuf]) -> Result<()> {
98    strixonomy_core::validate_workspace_scope_any(path, workspace_roots)
99        .map_err(RefactorError::Invalid)?;
100    Ok(())
101}
102
103fn canonical_path(path: &Path) -> PathBuf {
104    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
105}
106
107fn find_doc_by_path<'a>(
108    catalog: &'a OntologyCatalog,
109    path: &Path,
110) -> Option<&'a strixonomy_core::OntologyDocument> {
111    let canon = canonical_path(path);
112    catalog.data().documents.iter().find(|d| {
113        d.path == path || canonical_path(&d.path) == canon || canonical_path(&d.path) == path
114    })
115}
116
117fn resolve_import_doc<'a>(
118    catalog: &'a OntologyCatalog,
119    import_iri: &str,
120) -> Option<&'a strixonomy_core::OntologyDocument> {
121    let norm = normalize_iri(import_iri);
122    if let Some(doc) = catalog.data().documents.iter().find(|d| {
123        document_matches_ontology_id(import_iri, d)
124            || d.base_iri.as_ref().is_some_and(|b| normalize_iri(b) == norm || b == import_iri)
125            || normalize_iri(&d.id) == norm
126            || d.id == import_iri
127    }) {
128        return Some(doc);
129    }
130    // Protégé catalog-v001.xml / Oasis URI redirects (local only).
131    resolve_import_via_xml_catalog(catalog, import_iri)
132}
133
134/// Public import resolve: ontology IRI match, then workspace `catalog-v001.xml` redirects.
135pub fn resolve_import_document<'a>(
136    catalog: &'a OntologyCatalog,
137    import_iri: &str,
138) -> Option<&'a strixonomy_core::OntologyDocument> {
139    resolve_import_doc(catalog, import_iri)
140}
141
142fn resolve_import_via_xml_catalog<'a>(
143    catalog: &'a OntologyCatalog,
144    import_iri: &str,
145) -> Option<&'a strixonomy_core::OntologyDocument> {
146    use std::collections::BTreeSet;
147    let mut roots = BTreeSet::new();
148    for doc in &catalog.data().documents {
149        if let Some(parent) = doc.path.parent() {
150            roots.insert(parent.to_path_buf());
151            if let Some(grand) = parent.parent() {
152                roots.insert(grand.to_path_buf());
153            }
154        }
155    }
156    for root in roots {
157        let Ok(xml) = strixonomy_catalog::load_workspace_xml_catalogs(&root) else {
158            continue;
159        };
160        if xml.uri_entries.is_empty() && xml.rewrite_entries.is_empty() {
161            continue;
162        }
163        let Some(target) = xml.resolve(import_iri) else {
164            continue;
165        };
166        if let Some(doc) = find_doc_by_path(catalog, &target) {
167            return Some(doc);
168        }
169        // Match by file name when relative redirect didn't canonicalize
170        if let Some(name) = target.file_name() {
171            if let Some(doc) =
172                catalog.data().documents.iter().find(|d| d.path.file_name() == Some(name))
173            {
174                return Some(doc);
175            }
176        }
177    }
178    None
179}
180
181fn is_prefix_declaration_line(line: &str) -> bool {
182    let keyword = line.split_whitespace().next().unwrap_or("");
183    keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")
184}
185
186fn prefix_declaration_name(line: &str) -> Option<&str> {
187    let mut parts = line.split_whitespace();
188    let keyword = parts.next()?;
189    if !(keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")) {
190        return None;
191    }
192    parts.next()?.strip_suffix(':')
193}
194
195fn whole_file_change(path: PathBuf, original_text: String, preview_text: String) -> FileChange {
196    FileChange {
197        path,
198        hunks: vec![Hunk {
199            start_byte: 0,
200            end_byte: original_text.len() as u64,
201            old_text: original_text.clone(),
202            new_text: preview_text.clone(),
203        }],
204        preview_text,
205        original_text,
206    }
207}
208
209fn split_prefixes_and_body(text: &str) -> (Vec<String>, String) {
210    let mut prefixes = Vec::new();
211    let mut body_lines = Vec::new();
212    let mut in_prefix_header = true;
213    for line in text.lines() {
214        if in_prefix_header && (is_prefix_declaration_line(line) || line.trim().is_empty()) {
215            if is_prefix_declaration_line(line) {
216                prefixes.push(line.to_string());
217            }
218            continue;
219        }
220        in_prefix_header = false;
221        body_lines.push(line);
222    }
223    let body = body_lines.join("\n").trim().to_string();
224    (prefixes, body)
225}
226
227fn merge_missing_prefixes(target: &str, source_prefixes: &[String]) -> (String, Vec<String>) {
228    let target_names: BTreeSet<String> =
229        target.lines().filter_map(prefix_declaration_name).map(str::to_string).collect();
230    let mut missing = Vec::new();
231    for line in source_prefixes {
232        let Some(name) = prefix_declaration_name(line) else {
233            continue;
234        };
235        if !target_names.contains(name) {
236            missing.push(line.clone());
237        }
238    }
239    if missing.is_empty() {
240        return (target.to_string(), missing);
241    }
242    // Insert missing prefixes after existing prefix block (or at top).
243    let mut out = String::new();
244    let mut inserted = false;
245    let mut saw_prefix = false;
246    for line in target.split_inclusive('\n') {
247        if is_prefix_declaration_line(line) {
248            saw_prefix = true;
249            out.push_str(line);
250            continue;
251        }
252        if saw_prefix && !inserted && !is_prefix_declaration_line(line) {
253            for p in &missing {
254                out.push_str(p);
255                if !p.ends_with('\n') {
256                    out.push('\n');
257                }
258            }
259            out.push('\n');
260            inserted = true;
261        }
262        out.push_str(line);
263    }
264    if !inserted {
265        let mut header = missing.join("\n");
266        if !header.ends_with('\n') {
267            header.push('\n');
268        }
269        out = format!("{header}\n{target}");
270    }
271    (out, missing)
272}
273
274fn body_already_present(target: &str, body: &str) -> bool {
275    let trimmed = body.trim();
276    if trimmed.is_empty() {
277        return true;
278    }
279    // Exact substring match of the source body is enough for MVP dedupe.
280    target.contains(trimmed)
281}
282
283fn is_turtle_path(path: &Path) -> bool {
284    path.extension()
285        .and_then(|e| e.to_str())
286        .is_some_and(|e| e.eq_ignore_ascii_case("ttl") || e.eq_ignore_ascii_case("turtle"))
287}
288
289/// Merge source ontology Turtle documents into `target_file` (prefix union + body append).
290pub fn preview_merge_ontologies(
291    catalog: &OntologyCatalog,
292    source_paths: &[PathBuf],
293    target_file: &Path,
294    document_overrides: &HashMap<PathBuf, String>,
295    workspace_roots: &[PathBuf],
296) -> Result<RefactorPlan> {
297    require_path_in_workspace(target_file, workspace_roots)?;
298    if source_paths.is_empty() {
299        return Err(RefactorError::Invalid("no source ontology paths provided".to_string()));
300    }
301    for src in source_paths {
302        require_path_in_workspace(src, workspace_roots)?;
303    }
304
305    let target_canon = canonical_path(target_file);
306    let mut warnings = Vec::new();
307    let original = if target_file.exists() || document_overrides.contains_key(target_file) {
308        read_source_text(target_file, document_overrides)?
309    } else {
310        String::new()
311    };
312    let mut preview = original.clone();
313    let mut appended_entities = Vec::new();
314
315    for src in source_paths {
316        if canonical_path(src) == target_canon {
317            warnings.push(format!("skipping source equal to target: {}", src.display()));
318            continue;
319        }
320        if !is_turtle_path(src) {
321            warnings.push(format!("skipping non-Turtle source: {}", src.display()));
322            continue;
323        }
324        if let Some(doc) = find_doc_by_path(catalog, src) {
325            if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
326                warnings.push(format!("skipping non-Turtle or errored file: {}", src.display()));
327                continue;
328            }
329        }
330
331        let source_text = read_source_text(src, document_overrides)?;
332        let (prefixes, body) = split_prefixes_and_body(&source_text);
333        if body.is_empty() {
334            warnings.push(format!("empty body in source: {}", src.display()));
335            continue;
336        }
337        if body_already_present(&preview, &body) {
338            warnings.push(format!("source body already present in target: {}", src.display()));
339            continue;
340        }
341        let (with_prefixes, missing) = merge_missing_prefixes(&preview, &prefixes);
342        preview = with_prefixes;
343        if !missing.is_empty() {
344            warnings.push(format!(
345                "added {} missing @prefix declaration(s) from {}",
346                missing.len(),
347                src.display()
348            ));
349        }
350        if preview.trim().is_empty() {
351            preview = format!("{body}\n");
352        } else {
353            let mut next = preview.trim_end().to_string();
354            next.push_str("\n\n");
355            next.push_str(&body);
356            if !body.ends_with('\n') {
357                next.push('\n');
358            }
359            preview = next;
360        }
361        appended_entities.push(src.display().to_string());
362    }
363
364    if preview == original {
365        warnings.push("no ontology content merged into target".to_string());
366    }
367
368    let changes = if preview == original {
369        vec![]
370    } else {
371        vec![whole_file_change(target_file.to_path_buf(), original, preview)]
372    };
373
374    Ok(RefactorPlan { changes, warnings, ..Default::default() }.with_metrics(appended_entities))
375}
376
377fn collect_import_closure<'a>(
378    catalog: &'a OntologyCatalog,
379    root: &'a strixonomy_core::OntologyDocument,
380) -> (Vec<&'a strixonomy_core::OntologyDocument>, Vec<String>) {
381    let mut warnings = Vec::new();
382    let mut visited = BTreeSet::new();
383    let mut queue: Vec<String> = root.imports.clone();
384    let mut ordered = Vec::new();
385
386    visited.insert(canonical_path(&root.path));
387
388    while let Some(import_iri) = queue.pop() {
389        let Some(doc) = resolve_import_doc(catalog, &import_iri) else {
390            warnings.push(format!("unresolved import: {import_iri}"));
391            continue;
392        };
393        let canon = canonical_path(&doc.path);
394        if !visited.insert(canon) {
395            continue;
396        }
397        if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
398            warnings.push(format!(
399                "skipping non-Turtle or errored import: {} ({})",
400                import_iri,
401                doc.path.display()
402            ));
403            continue;
404        }
405        for child in &doc.imports {
406            queue.push(child.clone());
407        }
408        ordered.push(doc);
409    }
410    (ordered, warnings)
411}
412
413fn strip_owl_imports_lines(text: &str) -> (String, usize) {
414    let mut removed = 0usize;
415    let mut out = String::with_capacity(text.len());
416    for line in text.split_inclusive('\n') {
417        let trimmed = line.trim();
418        // Match owl:imports / <…owl#imports> predicate lines.
419        let is_import = trimmed.contains("owl:imports")
420            || trimmed.contains("owl#imports>")
421            || trimmed.to_ascii_lowercase().contains("owl:imports");
422        if is_import {
423            removed += 1;
424            continue;
425        }
426        out.push_str(line);
427    }
428    // Clean up dangling "a owl:Ontology ;" followed by "." after import removal.
429    let cleaned = cleanup_ontology_block_after_import_strip(&out);
430    (cleaned, removed)
431}
432
433fn cleanup_ontology_block_after_import_strip(text: &str) -> String {
434    // Turn `a owl:Ontology ;` / `\n.` sequences into `a owl:Ontology .`
435    let mut result = text.to_string();
436    // Common pattern: ontology IRI block ending with `;` then blank then `.`
437    while result.contains(";\n.") || result.contains(";\r\n.") {
438        result = result.replace(";\n.", ".");
439        result = result.replace(";\r\n.", ".");
440    }
441    // `a owl:Ontology ;\n\n` → keep; trailing semicolon before next subject is ok.
442    // Collapse `a owl:Ontology ;\n` at end of statement when next non-ws is unrelated:
443    result = result.replace("a owl:Ontology ;\n\n", "a owl:Ontology .\n\n");
444    result = result.replace("a owl:Ontology ;\n", "a owl:Ontology .\n");
445    while result.contains("\n\n\n") {
446        result = result.replace("\n\n\n", "\n\n");
447    }
448    result
449}
450
451fn extract_import_iris_from_text(text: &str) -> Vec<String> {
452    let mut iris = Vec::new();
453    for line in text.lines() {
454        let trimmed = line.trim();
455        if !(trimmed.contains("owl:imports") || trimmed.contains("owl#imports>")) {
456            continue;
457        }
458        // Prefer <IRI> on the import line.
459        if let Some(start) = trimmed.rfind('<') {
460            if let Some(end) = trimmed[start + 1..].find('>') {
461                let iri = &trimmed[start + 1..start + 1 + end];
462                if !iri.is_empty() {
463                    iris.push(iri.to_string());
464                }
465            }
466        }
467    }
468    iris
469}
470
471/// Inline axioms from imported Turtle documents into the root file and remove imports.
472pub fn preview_flatten_imports(
473    catalog: &OntologyCatalog,
474    ontology_file: &Path,
475    document_overrides: &HashMap<PathBuf, String>,
476    workspace_roots: &[PathBuf],
477) -> Result<RefactorPlan> {
478    require_path_in_workspace(ontology_file, workspace_roots)?;
479    let doc = find_doc_by_path(catalog, ontology_file).ok_or_else(|| {
480        RefactorError::Invalid(format!("ontology file not in catalog: {}", ontology_file.display()))
481    })?;
482    if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
483        return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
484    }
485
486    let original = read_source_text(&doc.path, document_overrides)?;
487    let mut warnings = Vec::new();
488    let (imports, mut import_warnings) = collect_import_closure(catalog, doc);
489    warnings.append(&mut import_warnings);
490
491    if imports.is_empty() && doc.imports.is_empty() {
492        warnings.push("no owl:imports to flatten".to_string());
493        return Ok(RefactorPlan { changes: vec![], warnings, ..Default::default() }
494            .with_metrics(Vec::<String>::new()));
495    }
496
497    let (mut preview, removed) = strip_owl_imports_lines(&original);
498    if removed == 0 && !doc.imports.is_empty() {
499        warnings.push(
500            "catalog lists imports but no owl:imports lines matched; left stub warnings".into(),
501        );
502        for iri in &doc.imports {
503            warnings.push(format!("# TODO: flatten unresolved/unmatched import {iri}"));
504        }
505    }
506
507    for imported in imports {
508        let source_text = read_source_text(&imported.path, document_overrides)?;
509        let (prefixes, body) = split_prefixes_and_body(&source_text);
510        if body.is_empty() {
511            continue;
512        }
513        if body_already_present(&preview, &body) {
514            warnings.push(format!(
515                "imported body already present, skipped: {}",
516                imported.path.display()
517            ));
518            continue;
519        }
520        let (with_prefixes, missing) = merge_missing_prefixes(&preview, &prefixes);
521        preview = with_prefixes;
522        if !missing.is_empty() {
523            warnings.push(format!(
524                "added {} missing @prefix declaration(s) from {}",
525                missing.len(),
526                imported.path.display()
527            ));
528        }
529        let mut next = preview.trim_end().to_string();
530        next.push_str("\n\n");
531        next.push_str("# inlined from ");
532        next.push_str(&imported.path.display().to_string());
533        next.push('\n');
534        next.push_str(&body);
535        if !body.ends_with('\n') {
536            next.push('\n');
537        }
538        preview = next;
539    }
540
541    if preview == original {
542        warnings.push("flatten produced no textual changes".to_string());
543        return Ok(RefactorPlan { changes: vec![], warnings, ..Default::default() }
544            .with_metrics(Vec::<String>::new()));
545    }
546
547    Ok(RefactorPlan {
548        changes: vec![whole_file_change(doc.path.clone(), original, preview)],
549        warnings,
550        ..Default::default()
551    }
552    .with_metrics(doc.imports.clone()))
553}
554
555fn entity_iris_for_document(
556    catalog: &OntologyCatalog,
557    doc: &strixonomy_core::OntologyDocument,
558) -> Vec<String> {
559    catalog
560        .data()
561        .entities
562        .iter()
563        .filter(|e| {
564            e.ontology_id == doc.id
565                || doc
566                    .base_iri
567                    .as_ref()
568                    .is_some_and(|b| normalize_iri(b) == normalize_iri(&e.ontology_id))
569                || catalog
570                    .entity_document(&e.iri)
571                    .is_some_and(|d| canonical_path(&d.path) == canonical_path(&doc.path))
572        })
573        .map(|e| e.iri.clone())
574        .collect()
575}
576
577fn text_references_any_entity(
578    text: &str,
579    entity_iris: &[String],
580    namespaces: &BTreeMap<String, String>,
581) -> bool {
582    for iri in entity_iris {
583        if text.contains(iri) {
584            return true;
585        }
586        let short = strixonomy_owl::short_name_from_iri(iri);
587        for (prefix, ns) in namespaces {
588            if iri.starts_with(ns.as_str()) {
589                let curie = if prefix.is_empty() {
590                    format!(":{short}")
591                } else {
592                    format!("{prefix}:{short}")
593                };
594                if text.contains(&curie) {
595                    return true;
596                }
597            }
598        }
599        // Default: angle form
600        if text.contains(&format!("<{iri}>")) {
601            return true;
602        }
603    }
604    false
605}
606
607fn remove_import_lines_for_iri(text: &str, import_iri: &str) -> String {
608    let mut out = String::with_capacity(text.len());
609    for line in text.split_inclusive('\n') {
610        if line.contains(import_iri)
611            && (line.contains("owl:imports") || line.contains("owl#imports>"))
612        {
613            continue;
614        }
615        out.push_str(line);
616    }
617    cleanup_ontology_block_after_import_strip(&out)
618}
619
620/// Remove unused `owl:imports` when none of the imported ontology's entities are referenced.
621pub fn preview_cleanup_imports(
622    catalog: &OntologyCatalog,
623    ontology_file: &Path,
624    document_overrides: &HashMap<PathBuf, String>,
625    workspace_roots: &[PathBuf],
626) -> Result<RefactorPlan> {
627    require_path_in_workspace(ontology_file, workspace_roots)?;
628    let doc = find_doc_by_path(catalog, ontology_file).ok_or_else(|| {
629        RefactorError::Invalid(format!("ontology file not in catalog: {}", ontology_file.display()))
630    })?;
631    if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
632        return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
633    }
634
635    let original = read_source_text(&doc.path, document_overrides)?;
636    let namespaces = strixonomy_owl::namespaces_for_text(&original, &doc.namespaces);
637    let mut warnings = Vec::new();
638    let mut preview = original.clone();
639    let mut removed_iris = Vec::new();
640
641    let mut import_iris: BTreeSet<String> = doc.imports.iter().cloned().collect();
642    for iri in extract_import_iris_from_text(&original) {
643        import_iris.insert(iri);
644    }
645
646    for import_iri in import_iris {
647        let Some(imported) = resolve_import_doc(catalog, &import_iri) else {
648            warnings.push(format!("leaving unresolved import: {import_iri}"));
649            continue;
650        };
651        if imported.format != OntologyFormat::Turtle {
652            warnings.push(format!("skipping non-Turtle import for cleanup: {import_iri}"));
653            continue;
654        }
655        let entities = entity_iris_for_document(catalog, imported);
656        // Build reference text without the import line itself.
657        let without_this_import = remove_import_lines_for_iri(&preview, &import_iri);
658        if entities.is_empty() {
659            // No indexed entities — treat as unused if the import IRI itself isn't otherwise used.
660            warnings.push(format!(
661                "imported ontology has no indexed entities; removing unused import {import_iri}"
662            ));
663            preview = without_this_import;
664            removed_iris.push(import_iri);
665            continue;
666        }
667        if text_references_any_entity(&without_this_import, &entities, &namespaces) {
668            continue;
669        }
670        preview = without_this_import;
671        removed_iris.push(import_iri);
672    }
673
674    if preview == original {
675        warnings.push("no unused owl:imports found".to_string());
676        return Ok(RefactorPlan { changes: vec![], warnings, ..Default::default() }
677            .with_metrics(Vec::<String>::new()));
678    }
679
680    if !removed_iris.is_empty() {
681        warnings.push(format!("removed {} unused owl:imports", removed_iris.len()));
682    }
683
684    Ok(RefactorPlan {
685        changes: vec![whole_file_change(doc.path.clone(), original, preview)],
686        warnings,
687        ..Default::default()
688    }
689    .with_metrics(removed_iris))
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn is_builtin_recognizes_owl_rdf() {
698        assert!(is_builtin_iri("http://www.w3.org/2002/07/owl#Class"));
699        assert!(is_builtin_iri("http://www.w3.org/2000/01/rdf-schema#subClassOf"));
700        assert!(!is_builtin_iri("http://example.org#Person"));
701    }
702
703    #[test]
704    fn strip_owl_imports_removes_lines() {
705        let text = concat!(
706            "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
707            "<http://example.org/root> a owl:Ontology ;\n",
708            "    owl:imports <http://example.org/lib> .\n",
709            "ex:A a owl:Class .\n"
710        );
711        let (out, n) = strip_owl_imports_lines(text);
712        assert_eq!(n, 1);
713        assert!(!out.contains("owl:imports"));
714        assert!(out.contains("ex:A"));
715    }
716}