ontoenv_cli/
lib.rs

1use anyhow::{Error, Result};
2use clap::{Parser, Subcommand};
3use log::info;
4use ontoenv::api::{OntoEnv, ResolveTarget};
5use ontoenv::config::Config;
6use ontoenv::ontology::{GraphIdentifier, OntologyLocation};
7use ontoenv::options::{Overwrite, RefreshStrategy};
8use ontoenv::util::write_dataset_to_file;
9use ontoenv::ToUriString;
10use oxigraph::io::{JsonLdProfileSet, RdfFormat};
11use oxigraph::model::NamedNode;
12use std::collections::{BTreeMap, BTreeSet};
13use std::env::current_dir;
14use std::ffi::OsString;
15use std::path::PathBuf;
16
17#[derive(Debug, Parser)]
18#[command(name = "ontoenv")]
19#[command(about = "Ontology environment manager")]
20#[command(arg_required_else_help = true)]
21struct Cli {
22    #[command(subcommand)]
23    command: Commands,
24    /// Verbose mode - sets the RUST_LOG level to info, defaults to warning level
25    #[clap(long, short, action, default_value = "false", global = true)]
26    verbose: bool,
27    /// Debug mode - sets the RUST_LOG level to debug, defaults to warning level
28    #[clap(long, action, default_value = "false", global = true)]
29    debug: bool,
30    /// Resolution policy for determining which ontology to use when there are multiple with the same name
31    #[clap(long, short, default_value = "default", global = true)]
32    policy: Option<String>,
33    /// Temporary (non-persistent) mode - will not save the environment to disk
34    #[clap(long, short, action, global = true)]
35    temporary: bool,
36    /// Require ontology names to be unique; will raise an error if multiple ontologies have the same name
37    #[clap(long, action, global = true)]
38    require_ontology_names: bool,
39    /// Strict mode - will raise an error if an ontology is not found
40    #[clap(long, action, default_value = "false", global = true)]
41    strict: bool,
42    /// Offline mode - will not attempt to fetch ontologies from the web
43    #[clap(long, short, action, default_value = "false", global = true)]
44    offline: bool,
45    /// Glob patterns for which files to include, defaults to ['*.ttl','*.xml','*.n3']
46    #[clap(long, short, num_args = 1.., global = true)]
47    includes: Vec<String>,
48    /// Glob patterns for which files to exclude, defaults to []
49    #[clap(long, short, num_args = 1.., global = true)]
50    excludes: Vec<String>,
51    /// Do not search for ontologies in the search directories
52    #[clap(long = "no-search", short = 'n', action, global = true)]
53    no_search: bool,
54}
55
56#[derive(Debug, Subcommand)]
57enum ConfigCommands {
58    /// Set a configuration value.
59    Set {
60        /// The configuration key to set.
61        key: String,
62        /// The value to set for the key.
63        value: String,
64    },
65    /// Get a configuration value.
66    Get {
67        /// The configuration key to get.
68        key: String,
69    },
70    /// Unset a configuration value, reverting to its default.
71    Unset {
72        /// The configuration key to unset.
73        key: String,
74    },
75    /// Add a value to a list-based configuration key.
76    Add {
77        /// The configuration key to add to.
78        key: String,
79        /// The value to add.
80        value: String,
81    },
82    /// Remove a value from a list-based configuration key.
83    Remove {
84        /// The configuration key to remove from.
85        key: String,
86        /// The value to remove.
87        value: String,
88    },
89    /// List all configuration values.
90    List,
91}
92
93#[derive(Debug, Subcommand)]
94enum ListCommands {
95    /// List all ontology locations found in the search paths
96    Locations,
97    /// List all declared ontologies in the environment
98    Ontologies,
99    /// List all missing imports
100    Missing,
101}
102
103#[derive(Debug, Subcommand)]
104enum Commands {
105    /// Create a new ontology environment
106    Init {
107        /// Overwrite the environment if it already exists
108        #[clap(long, default_value = "false")]
109        overwrite: bool,
110        /// Directories to search for ontologies. If not provided, the current directory is used.
111        #[clap(last = true)]
112        locations: Option<Vec<PathBuf>>,
113    },
114    /// Prints the version of the ontoenv binary
115    Version,
116    /// Prints the status of the ontology environment
117    Status {
118        /// Output JSON instead of text
119        #[clap(long, action, default_value = "false")]
120        json: bool,
121    },
122    /// Update the ontology environment
123    Update {
124        /// Suppress per-ontology update output
125        #[clap(long, short = 'q', action)]
126        quiet: bool,
127        /// Update all ontologies, ignoring modification times
128        #[clap(long, short = 'a', action)]
129        all: bool,
130        /// Output JSON instead of text
131        #[clap(long, action, default_value = "false")]
132        json: bool,
133    },
134    /// Compute the owl:imports closure of an ontology and write it to a file
135    Closure {
136        /// The name (URI) of the ontology to compute the closure for
137        ontology: String,
138        /// Do NOT rewrite sh:prefixes (rewrite is ON by default)
139        #[clap(long, action, default_value = "false")]
140        no_rewrite_sh_prefixes: bool,
141        /// Keep owl:imports statements (removal is ON by default)
142        #[clap(long, action, default_value = "false")]
143        keep_owl_imports: bool,
144        /// The file to write the closure to, defaults to 'output.ttl'
145        destination: Option<String>,
146        /// The recursion depth for exploring owl:imports. <0: unlimited, 0: no imports, >0:
147        /// specific depth.
148        #[clap(long, default_value = "-1")]
149        recursion_depth: i32,
150    },
151    /// Retrieve a single graph from the environment and write it to STDOUT or a file
152    Get {
153        /// Ontology IRI (name)
154        ontology: String,
155        /// Optional source location (file path or URL) to disambiguate
156        #[clap(long, short = 'l')]
157        location: Option<String>,
158        /// Output file path; if omitted, writes to STDOUT
159        #[clap(long)]
160        output: Option<String>,
161        /// Serialization format: one of [turtle, ntriples, rdfxml, jsonld] (default: turtle)
162        #[clap(long, short = 'f')]
163        format: Option<String>,
164    },
165    /// Add an ontology to the environment
166    Add {
167        /// The location of the ontology to add (file path or URL)
168        location: String,
169        /// Do not explore owl:imports of the added ontology
170        #[clap(long, action)]
171        no_imports: bool,
172    },
173    /// List various properties of the environment
174    /// List various properties of the environment
175    List {
176        #[command(subcommand)]
177        list_cmd: ListCommands,
178        /// Output JSON instead of text
179        #[clap(long, action, default_value = "false")]
180        json: bool,
181    },
182    // TODO: dump all ontologies; nest by ontology name (sorted), w/n each ontology name list all
183    // the places where that graph can be found. List basic stats: the metadata field in the
184    // Ontology struct and # of triples in the graph; last updated; etc
185    /// Print out the current state of the ontology environment
186    Dump {
187        /// Filter the output to only include ontologies that contain the given string in their
188        /// name. Leave empty to include all ontologies.
189        contains: Option<String>,
190    },
191    /// Generate a PDF of the dependency graph
192    DepGraph {
193        /// The root ontologies to start the graph from. Given by name (URI)
194        roots: Option<Vec<String>>,
195        /// The output file to write the PDF to, defaults to 'dep_graph.pdf'
196        #[clap(long, short)]
197        output: Option<String>,
198    },
199    /// Lists which ontologies import the given ontology
200    Why {
201        /// The name (URI) of the ontology to find importers for
202        ontologies: Vec<String>,
203        /// Output JSON instead of text
204        #[clap(long, action, default_value = "false")]
205        json: bool,
206    },
207    /// Run the doctor to check the environment for issues
208    Doctor {
209        /// Output JSON instead of text
210        #[clap(long, action, default_value = "false")]
211        json: bool,
212    },
213    /// Reset the ontology environment by removing the .ontoenv directory
214    Reset {
215        #[clap(long, short, action = clap::ArgAction::SetTrue, default_value = "false")]
216        force: bool,
217    },
218    /// Manage ontoenv configuration.
219    #[command(subcommand)]
220    Config(ConfigCommands),
221}
222
223impl ToString for Commands {
224    fn to_string(&self) -> String {
225        match self {
226            Commands::Init { .. } => "Init".to_string(),
227            Commands::Version => "Version".to_string(),
228            Commands::Status { .. } => "Status".to_string(),
229            Commands::Update { .. } => "Update".to_string(),
230            Commands::Closure { .. } => "Closure".to_string(),
231            Commands::Get { .. } => "Get".to_string(),
232            Commands::Add { .. } => "Add".to_string(),
233            Commands::List { .. } => "List".to_string(),
234            Commands::Dump { .. } => "Dump".to_string(),
235            Commands::DepGraph { .. } => "DepGraph".to_string(),
236            Commands::Why { .. } => "Why".to_string(),
237            Commands::Doctor { .. } => "Doctor".to_string(),
238            Commands::Reset { .. } => "Reset".to_string(),
239            Commands::Config { .. } => "Config".to_string(),
240        }
241    }
242}
243
244fn handle_config_command(config_cmd: ConfigCommands, temporary: bool) -> Result<()> {
245    if temporary {
246        return Err(anyhow::anyhow!("Cannot manage config in temporary mode."));
247    }
248    let root = ontoenv::api::find_ontoenv_root()
249        .ok_or_else(|| anyhow::anyhow!("Not in an ontoenv. Use `ontoenv init` to create one."))?;
250    let config_path = root.join(".ontoenv").join("ontoenv.json");
251    if !config_path.exists() {
252        return Err(anyhow::anyhow!(
253            "No ontoenv.json found. Use `ontoenv init`."
254        ));
255    }
256
257    match config_cmd {
258        ConfigCommands::List => {
259            let config_str = std::fs::read_to_string(&config_path)?;
260            let config_json: serde_json::Value = serde_json::from_str(&config_str)?;
261            let pretty_json = serde_json::to_string_pretty(&config_json)?;
262            println!("{}", pretty_json);
263            return Ok(());
264        }
265        ConfigCommands::Get { ref key } => {
266            let config_str = std::fs::read_to_string(&config_path)?;
267            let config_json: serde_json::Value = serde_json::from_str(&config_str)?;
268            let object = config_json
269                .as_object()
270                .ok_or_else(|| anyhow::anyhow!("Invalid config format: not a JSON object."))?;
271
272            if let Some(value) = object.get(key) {
273                if let Some(s) = value.as_str() {
274                    println!("{}", s);
275                } else if let Some(arr) = value.as_array() {
276                    for item in arr {
277                        if let Some(s) = item.as_str() {
278                            println!("{}", s);
279                        } else {
280                            println!("{}", item);
281                        }
282                    }
283                } else {
284                    println!("{}", value);
285                }
286            } else {
287                println!("Configuration key '{}' not set.", key);
288            }
289            return Ok(());
290        }
291        _ => {}
292    }
293
294    // Modifying commands continue here.
295    let config_str = std::fs::read_to_string(&config_path)?;
296    let mut config_json: serde_json::Value = serde_json::from_str(&config_str)?;
297
298    let object = config_json
299        .as_object_mut()
300        .ok_or_else(|| anyhow::anyhow!("Invalid config format: not a JSON object."))?;
301
302    match config_cmd {
303        ConfigCommands::Set { key, value } => {
304            match key.as_str() {
305                "offline" | "strict" | "require_ontology_names" | "no_search" => {
306                    let bool_val = value.parse::<bool>().map_err(|_| {
307                        anyhow::anyhow!("Invalid boolean value for {}: {}", key, value)
308                    })?;
309                    object.insert(key.to_string(), serde_json::Value::Bool(bool_val));
310                }
311                "resolution_policy" => {
312                    object.insert(key.to_string(), serde_json::Value::String(value.clone()));
313                }
314                "locations" | "includes" | "excludes" => {
315                    return Err(anyhow::anyhow!(
316                        "Use `ontoenv config add/remove {} <value>` to modify list values.",
317                        key
318                    ));
319                }
320                _ => {
321                    return Err(anyhow::anyhow!(
322                        "Setting configuration for '{}' is not supported.",
323                        key
324                    ));
325                }
326            }
327            println!("Set {} to {}", key, value);
328        }
329        ConfigCommands::Unset { key } => {
330            if object.remove(&key).is_some() {
331                println!("Unset '{}'.", key);
332            } else {
333                return Err(anyhow::anyhow!("Configuration key '{}' not set.", key));
334            }
335        }
336        ConfigCommands::Add { key, value } => {
337            match key.as_str() {
338                "locations" | "includes" | "excludes" => {
339                    let entry = object
340                        .entry(key.clone())
341                        .or_insert_with(|| serde_json::Value::Array(vec![]));
342                    if let Some(arr) = entry.as_array_mut() {
343                        let new_val = serde_json::Value::String(value.clone());
344                        if !arr.contains(&new_val) {
345                            arr.push(new_val);
346                        } else {
347                            println!("Value '{}' already exists in {}.", value, key);
348                            return Ok(());
349                        }
350                    }
351                }
352                _ => {
353                    return Err(anyhow::anyhow!(
354                        "Cannot add to configuration key '{}'. It is not a list.",
355                        key
356                    ));
357                }
358            }
359            println!("Added '{}' to {}", value, key);
360        }
361        ConfigCommands::Remove { key, value } => {
362            match key.as_str() {
363                "locations" | "includes" | "excludes" => {
364                    if let Some(entry) = object.get_mut(&key) {
365                        if let Some(arr) = entry.as_array_mut() {
366                            let val_to_remove = serde_json::Value::String(value.clone());
367                            if let Some(pos) = arr.iter().position(|x| *x == val_to_remove) {
368                                arr.remove(pos);
369                            } else {
370                                return Err(anyhow::anyhow!(
371                                    "Value '{}' not found in {}",
372                                    value,
373                                    key
374                                ));
375                            }
376                        }
377                    } else {
378                        return Err(anyhow::anyhow!("Configuration key '{}' not set.", key));
379                    }
380                }
381                _ => {
382                    return Err(anyhow::anyhow!(
383                        "Cannot remove from configuration key '{}'. It is not a list.",
384                        key
385                    ));
386                }
387            }
388            println!("Removed '{}' from {}", value, key);
389        }
390        _ => unreachable!(), // Get and List are handled above
391    }
392
393    let new_config_str = serde_json::to_string_pretty(&config_json)?;
394    std::fs::write(config_path, new_config_str)?;
395
396    Ok(())
397}
398
399pub fn run() -> Result<()> {
400    ontoenv::api::init_logging();
401    let cmd = Cli::parse();
402    execute(cmd)
403}
404
405pub fn run_from_args<I, T>(args: I) -> Result<()>
406where
407    I: IntoIterator<Item = T>,
408    T: Into<OsString> + Clone,
409{
410    ontoenv::api::init_logging();
411    let cmd = Cli::try_parse_from(args).map_err(Error::from)?;
412    execute(cmd)
413}
414
415fn execute(cmd: Cli) -> Result<()> {
416    // The RUST_LOG env var is set by `init_logging` if ONTOENV_LOG is present.
417    // CLI flags for verbosity take precedence. If nothing is set, we default to "warn".
418    if cmd.debug {
419        std::env::set_var("RUST_LOG", "debug");
420    } else if cmd.verbose {
421        std::env::set_var("RUST_LOG", "info");
422    } else if std::env::var("RUST_LOG").is_err() {
423        // If no CLI flags and no env var is set, default to "warn".
424        std::env::set_var("RUST_LOG", "warn");
425    }
426    let _ = env_logger::try_init();
427
428    let policy = cmd.policy.unwrap_or_else(|| "default".to_string());
429
430    let mut builder = Config::builder()
431        .root(current_dir()?)
432        .require_ontology_names(cmd.require_ontology_names)
433        .strict(cmd.strict)
434        .offline(cmd.offline)
435        .resolution_policy(policy)
436        .temporary(cmd.temporary)
437        .no_search(cmd.no_search);
438
439    // Locations only apply to `init`; other commands ignore positional LOCATIONS
440    if let Commands::Init {
441        locations: Some(locs),
442        ..
443    } = &cmd.command
444    {
445        builder = builder.locations(locs.clone());
446    }
447    // only set includes if they are provided on the command line, otherwise use builder defaults
448    if !cmd.includes.is_empty() {
449        builder = builder.includes(&cmd.includes);
450    }
451    if !cmd.excludes.is_empty() {
452        builder = builder.excludes(&cmd.excludes);
453    }
454
455    let config: Config = builder.build()?;
456
457    if cmd.verbose || cmd.debug {
458        config.print();
459    }
460
461    if let Commands::Reset { force } = &cmd.command {
462        if let Some(root) = ontoenv::api::find_ontoenv_root() {
463            let path = root.join(".ontoenv");
464            println!("Removing .ontoenv directory at {}...", path.display());
465            if !*force {
466                // check delete? [y/N]
467                let mut input = String::new();
468                println!("Are you sure you want to delete the .ontoenv directory? [y/N] ");
469                std::io::stdin()
470                    .read_line(&mut input)
471                    .expect("Failed to read line");
472                let input = input.trim();
473                if input != "y" && input != "Y" {
474                    println!("Aborting...");
475                    return Ok(());
476                }
477            }
478            OntoEnv::reset()?;
479            println!(".ontoenv directory removed.");
480        } else {
481            println!("No .ontoenv directory found. Nothing to do.");
482        }
483        return Ok(());
484    }
485
486    // Discover environment root: ONTOENV_DIR takes precedence, else walk parents
487    let env_dir_var = std::env::var("ONTOENV_DIR").ok().map(PathBuf::from);
488    let discovered_root = if let Some(dir) = env_dir_var.clone() {
489        // If ONTOENV_DIR points to the .ontoenv directory, take its parent as root
490        if dir.file_name().map(|n| n == ".ontoenv").unwrap_or(false) {
491            dir.parent().map(|p| p.to_path_buf())
492        } else {
493            Some(dir)
494        }
495    } else {
496        ontoenv::api::find_ontoenv_root()
497    };
498    let ontoenv_exists = discovered_root
499        .as_ref()
500        .map(|root| root.join(".ontoenv").join("ontoenv.json").exists())
501        .unwrap_or(false);
502    info!("OntoEnv exists: {ontoenv_exists}");
503
504    // create the env object to use in the subcommand.
505    // - if temporary is true, create a new env object each time
506    // - if temporary is false, load the env from the .ontoenv directory if it exists
507    // Determine if this command needs write access to the store
508    let needs_rw = matches!(cmd.command, Commands::Add { .. } | Commands::Update { .. });
509
510    let env: Option<OntoEnv> = if cmd.temporary {
511        // Create a new OntoEnv object in temporary mode
512        let e = OntoEnv::init(config.clone(), false)?;
513        Some(e)
514    } else if cmd.command.to_string() != "Init" && ontoenv_exists {
515        // if .ontoenv exists, load it from discovered root
516        // Open read-only unless the command requires write access
517        Some(OntoEnv::load_from_directory(
518            discovered_root.unwrap(),
519            !needs_rw,
520        )?)
521    } else {
522        None
523    };
524    info!("OntoEnv loaded: {}", env.is_some());
525
526    match cmd.command {
527        Commands::Init { overwrite, .. } => {
528            // if temporary, raise an error
529            if cmd.temporary {
530                return Err(anyhow::anyhow!(
531                    "Cannot initialize in temporary mode. Run `ontoenv init` without --temporary."
532                ));
533            }
534
535            let root = current_dir()?;
536            if root.join(".ontoenv").exists() && !overwrite {
537                println!(
538                    "An ontology environment already exists in: {}",
539                    root.display()
540                );
541                println!("Use --overwrite to re-initialize or `ontoenv update` to update.");
542
543                let env = OntoEnv::load_from_directory(root, false)?;
544                let status = env.status()?;
545                println!("\nCurrent status:");
546                println!("{status}");
547                return Ok(());
548            }
549
550            // The call to `init` will create and update the environment.
551            // `update` will also save it to the directory.
552            let _ = OntoEnv::init(config, overwrite)?;
553        }
554        Commands::Get {
555            ontology,
556            location,
557            output,
558            format,
559        } => {
560            let env = require_ontoenv(env)?;
561
562            // If a location is provided, resolve by location. Otherwise resolve by name (IRI).
563            let graph = if let Some(loc) = location {
564                let oloc = if loc.starts_with("http://") || loc.starts_with("https://") {
565                    OntologyLocation::Url(loc)
566                } else {
567                    // Normalize to absolute path
568                    ontoenv::ontology::OntologyLocation::from_str(&loc)
569                        .unwrap_or_else(|_| OntologyLocation::File(PathBuf::from(loc)))
570                };
571                // Read directly from the specified location to disambiguate
572                oloc.graph()?
573            } else {
574                let iri = NamedNode::new(ontology).map_err(|e| anyhow::anyhow!(e.to_string()))?;
575                let graphid = env
576                    .resolve(ResolveTarget::Graph(iri))
577                    .ok_or(anyhow::anyhow!("Ontology not found"))?;
578                env.get_graph(&graphid)?
579            };
580
581            let fmt = match format
582                .as_deref()
583                .unwrap_or("turtle")
584                .to_ascii_lowercase()
585                .as_str()
586            {
587                "turtle" | "ttl" => RdfFormat::Turtle,
588                "ntriples" | "nt" => RdfFormat::NTriples,
589                "rdfxml" | "xml" => RdfFormat::RdfXml,
590                "jsonld" | "json-ld" => RdfFormat::JsonLd {
591                    profile: JsonLdProfileSet::default(),
592                },
593                other => {
594                    return Err(anyhow::anyhow!(
595                        "Unsupported format '{}'. Use one of: turtle, ntriples, rdfxml, jsonld",
596                        other
597                    ))
598                }
599            };
600
601            if let Some(path) = output {
602                let mut file = std::fs::File::create(path)?;
603                let mut serializer =
604                    oxigraph::io::RdfSerializer::from_format(fmt).for_writer(&mut file);
605                for t in graph.iter() {
606                    serializer.serialize_triple(t)?;
607                }
608                serializer.finish()?;
609            } else {
610                let stdout = std::io::stdout();
611                let mut handle = stdout.lock();
612                let mut serializer =
613                    oxigraph::io::RdfSerializer::from_format(fmt).for_writer(&mut handle);
614                for t in graph.iter() {
615                    serializer.serialize_triple(t)?;
616                }
617                serializer.finish()?;
618            }
619        }
620        Commands::Version => {
621            println!(
622                "ontoenv {} @ {}",
623                env!("CARGO_PKG_VERSION"),
624                env!("GIT_HASH")
625            );
626        }
627        Commands::Status { json } => {
628            let env = require_ontoenv(env)?;
629            if json {
630                // Recompute status details similar to env.status()
631                let ontoenv_dir = current_dir()?.join(".ontoenv");
632                let last_updated = if ontoenv_dir.exists() {
633                    Some(std::fs::metadata(&ontoenv_dir)?.modified()?)
634                        as Option<std::time::SystemTime>
635                } else {
636                    None
637                };
638                let size: u64 = if ontoenv_dir.exists() {
639                    walkdir::WalkDir::new(&ontoenv_dir)
640                        .into_iter()
641                        .filter_map(Result::ok)
642                        .filter(|e| e.file_type().is_file())
643                        .filter_map(|e| e.metadata().ok())
644                        .map(|m| m.len())
645                        .sum()
646                } else {
647                    0
648                };
649                let missing: Vec<String> = env
650                    .missing_imports()
651                    .into_iter()
652                    .map(|n| n.to_uri_string())
653                    .collect();
654                let last_str =
655                    last_updated.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339());
656                let obj = serde_json::json!({
657                    "exists": true,
658                    "num_ontologies": env.ontologies().len(),
659                    "last_updated": last_str,
660                    "store_size_bytes": size,
661                    "missing_imports": missing,
662                });
663                println!("{}", serde_json::to_string_pretty(&obj)?);
664            } else {
665                let status = env.status()?;
666                println!("{status}");
667            }
668        }
669        Commands::Update { quiet, all, json } => {
670            let mut env = require_ontoenv(env)?;
671            let updated = env.update_all(all)?;
672            if json {
673                let arr: Vec<String> = updated.iter().map(|id| id.to_uri_string()).collect();
674                println!("{}", serde_json::to_string_pretty(&arr)?);
675            } else if !quiet {
676                for id in updated {
677                    if let Some(ont) = env.ontologies().get(&id) {
678                        let name = ont.name().to_string();
679                        let loc = ont
680                            .location()
681                            .map(|l| l.to_string())
682                            .unwrap_or_else(|| "N/A".to_string());
683                        println!("{} @ {}", name, loc);
684                    }
685                }
686            }
687            env.save_to_directory()?;
688        }
689        Commands::Closure {
690            ontology,
691            no_rewrite_sh_prefixes,
692            keep_owl_imports,
693            destination,
694            recursion_depth,
695        } => {
696            // make ontology an IRI
697            let iri = NamedNode::new(ontology).map_err(|e| anyhow::anyhow!(e.to_string()))?;
698            let env = require_ontoenv(env)?;
699            let graphid = env
700                .resolve(ResolveTarget::Graph(iri.clone()))
701                .ok_or(anyhow::anyhow!(format!("Ontology {} not found", iri)))?;
702            let closure = env.get_closure(&graphid, recursion_depth)?;
703            // Defaults: rewrite prefixes = ON, remove owl:imports = ON; flags disable these.
704            let rewrite = !no_rewrite_sh_prefixes;
705            let remove = !keep_owl_imports;
706            let union = env.get_union_graph(&closure, Some(rewrite), Some(remove))?;
707            if let Some(failed_imports) = union.failed_imports {
708                for imp in failed_imports {
709                    eprintln!("{imp}");
710                }
711            }
712            // write the graph to a file
713            let destination = destination.unwrap_or_else(|| "output.ttl".to_string());
714            write_dataset_to_file(&union.dataset, &destination)?;
715        }
716        Commands::Add {
717            location,
718            no_imports,
719        } => {
720            let location = if location.starts_with("http") {
721                OntologyLocation::Url(location)
722            } else {
723                OntologyLocation::File(PathBuf::from(location))
724            };
725            let mut env = require_ontoenv(env)?;
726            if no_imports {
727                let _ =
728                    env.add_no_imports(location, Overwrite::Allow, RefreshStrategy::UseCache)?;
729            } else {
730                let _ = env.add(location, Overwrite::Allow, RefreshStrategy::UseCache)?;
731            }
732        }
733        Commands::List { list_cmd, json } => {
734            let env = require_ontoenv(env)?;
735            match list_cmd {
736                ListCommands::Locations => {
737                    let mut locations = env.find_files()?;
738                    locations.sort_by(|a, b| a.as_str().cmp(b.as_str()));
739                    if json {
740                        println!("{}", serde_json::to_string_pretty(&locations)?);
741                    } else {
742                        for loc in locations {
743                            println!("{}", loc);
744                        }
745                    }
746                }
747                ListCommands::Ontologies => {
748                    // print list of ontology URLs from env.ontologies.values() sorted alphabetically
749                    let mut ontologies: Vec<&GraphIdentifier> = env.ontologies().keys().collect();
750                    ontologies.sort_by(|a, b| a.name().cmp(&b.name()));
751                    ontologies.dedup_by(|a, b| a.name() == b.name());
752                    if json {
753                        let out: Vec<String> =
754                            ontologies.into_iter().map(|o| o.to_uri_string()).collect();
755                        println!("{}", serde_json::to_string_pretty(&out)?);
756                    } else {
757                        for ont in ontologies {
758                            println!("{}", ont.to_uri_string());
759                        }
760                    }
761                }
762                ListCommands::Missing => {
763                    let mut missing_imports = env.missing_imports();
764                    missing_imports.sort();
765                    if json {
766                        let out: Vec<String> = missing_imports
767                            .into_iter()
768                            .map(|n| n.to_uri_string())
769                            .collect();
770                        println!("{}", serde_json::to_string_pretty(&out)?);
771                    } else {
772                        for import in missing_imports {
773                            println!("{}", import.to_uri_string());
774                        }
775                    }
776                }
777            }
778        }
779        Commands::Dump { contains } => {
780            let env = require_ontoenv(env)?;
781            env.dump(contains.as_deref());
782        }
783        Commands::DepGraph { roots, output } => {
784            let env = require_ontoenv(env)?;
785            let dot = if let Some(roots) = roots {
786                let roots: Vec<GraphIdentifier> = roots
787                    .iter()
788                    .map(|iri| {
789                        env.resolve(ResolveTarget::Graph(NamedNode::new(iri).unwrap()))
790                            .unwrap()
791                            .clone()
792                    })
793                    .collect();
794                env.rooted_dep_graph_to_dot(roots)?
795            } else {
796                env.dep_graph_to_dot()?
797            };
798            // call graphviz to generate PDF
799            let dot_path = current_dir()?.join("dep_graph.dot");
800            std::fs::write(&dot_path, dot)?;
801            let output_path = output.unwrap_or_else(|| "dep_graph.pdf".to_string());
802            let output = std::process::Command::new("dot")
803                .args(["-Tpdf", dot_path.to_str().unwrap(), "-o", &output_path])
804                .output()?;
805            if !output.status.success() {
806                return Err(anyhow::anyhow!(
807                    "Failed to generate PDF: {}",
808                    String::from_utf8_lossy(&output.stderr)
809                ));
810            }
811        }
812        Commands::Why { ontologies, json } => {
813            let env = require_ontoenv(env)?;
814            if json {
815                let mut all: BTreeMap<String, Vec<Vec<String>>> = BTreeMap::new();
816                for ont in ontologies {
817                    let iri = NamedNode::new(ont).map_err(|e| anyhow::anyhow!(e.to_string()))?;
818                    let (paths, missing) = match env.explain_import(&iri)? {
819                        ontoenv::api::ImportPaths::Present(paths) => (paths, false),
820                        ontoenv::api::ImportPaths::Missing { importers } => (importers, true),
821                    };
822                    let formatted = format_import_paths(&iri, paths, missing);
823                    all.insert(iri.to_uri_string(), formatted);
824                }
825                println!("{}", serde_json::to_string_pretty(&all)?);
826            } else {
827                for ont in ontologies {
828                    let iri = NamedNode::new(ont).map_err(|e| anyhow::anyhow!(e.to_string()))?;
829                    match env.explain_import(&iri)? {
830                        ontoenv::api::ImportPaths::Present(paths) => {
831                            print_import_paths(&iri, paths, false);
832                        }
833                        ontoenv::api::ImportPaths::Missing { importers } => {
834                            print_import_paths(&iri, importers, true);
835                        }
836                    }
837                }
838            }
839        }
840        Commands::Doctor { json } => {
841            let env = require_ontoenv(env)?;
842            let problems = env.doctor()?;
843            if json {
844                let out: Vec<serde_json::Value> = problems
845                    .into_iter()
846                    .map(|p| serde_json::json!({
847                        "message": p.message,
848                        "locations": p.locations.into_iter().map(|loc| loc.to_string()).collect::<Vec<_>>()
849                    }))
850                    .collect();
851                println!("{}", serde_json::to_string_pretty(&out)?);
852            } else if problems.is_empty() {
853                println!("No issues found.");
854            } else {
855                println!("Found {} issues:", problems.len());
856                for problem in problems {
857                    println!("- {}", problem.message);
858                    for location in problem.locations {
859                        println!("  - {location}");
860                    }
861                }
862            }
863        }
864        Commands::Config(config_cmd) => {
865            handle_config_command(config_cmd, cmd.temporary)?;
866        }
867        Commands::Reset { .. } => {
868            // This command is handled before the environment is loaded.
869        }
870    }
871
872    Ok(())
873}
874
875fn require_ontoenv(env: Option<OntoEnv>) -> Result<OntoEnv> {
876    env.ok_or_else(|| {
877        anyhow::anyhow!("OntoEnv not found. Run `ontoenv init` to create a new OntoEnv or use -t/--temporary to use a temporary environment.")
878    })
879}
880
881fn format_import_paths(
882    target: &NamedNode,
883    paths: Vec<Vec<GraphIdentifier>>,
884    missing: bool,
885) -> Vec<Vec<String>> {
886    let mut unique: BTreeSet<Vec<String>> = BTreeSet::new();
887    if paths.is_empty() {
888        if missing {
889            unique.insert(vec![format!("{} (missing)", target.to_uri_string())]);
890        }
891        return unique.into_iter().collect();
892    }
893    for path in paths {
894        let mut entries: Vec<String> = path.into_iter().map(|id| id.to_uri_string()).collect();
895        if missing {
896            entries.push(format!("{} (missing)", target.to_uri_string()));
897        }
898        unique.insert(entries);
899    }
900    unique.into_iter().collect()
901}
902
903fn print_import_paths(target: &NamedNode, paths: Vec<Vec<GraphIdentifier>>, missing: bool) {
904    if paths.is_empty() {
905        if missing {
906            println!(
907                "Ontology {} is missing but no importers reference it.",
908                target.to_uri_string()
909            );
910        } else {
911            println!("No importers found for {}", target.to_uri_string());
912        }
913        return;
914    }
915
916    println!(
917        "Why {}{}:",
918        target.to_uri_string(),
919        if missing { " (missing)" } else { "" }
920    );
921
922    let mut lines: BTreeSet<String> = BTreeSet::new();
923    for path in paths {
924        let mut segments: Vec<String> = path.into_iter().map(|id| id.to_uri_string()).collect();
925        if missing {
926            segments.push(format!("{} (missing)", target.to_uri_string()));
927        }
928        lines.insert(segments.join(" -> "));
929    }
930
931    for line in lines {
932        println!("{}", line);
933    }
934}