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