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    /// Do not search for ontologies in the search directories
59    #[clap(long = "no-search", short = 'n', action, global = true)]
60    no_search: bool,
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 not provided, the current directory is used.
118        #[clap(last = true)]
119        locations: Option<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" | "no_search" => {
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                "locations" | "includes" | "excludes" => {
322                    return Err(anyhow::anyhow!(
323                        "Use `ontoenv config add/remove {} <value>` to modify list values.",
324                        key
325                    ));
326                }
327                _ => {
328                    return Err(anyhow::anyhow!(
329                        "Setting configuration for '{}' is not supported.",
330                        key
331                    ));
332                }
333            }
334            println!("Set {} to {}", key, value);
335        }
336        ConfigCommands::Unset { key } => {
337            if object.remove(&key).is_some() {
338                println!("Unset '{}'.", key);
339            } else {
340                return Err(anyhow::anyhow!("Configuration key '{}' not set.", key));
341            }
342        }
343        ConfigCommands::Add { key, value } => {
344            match key.as_str() {
345                "locations" | "includes" | "excludes" => {
346                    let entry = object
347                        .entry(key.clone())
348                        .or_insert_with(|| serde_json::Value::Array(vec![]));
349                    if let Some(arr) = entry.as_array_mut() {
350                        let new_val = serde_json::Value::String(value.clone());
351                        if !arr.contains(&new_val) {
352                            arr.push(new_val);
353                        } else {
354                            println!("Value '{}' already exists in {}.", value, key);
355                            return Ok(());
356                        }
357                    }
358                }
359                _ => {
360                    return Err(anyhow::anyhow!(
361                        "Cannot add to configuration key '{}'. It is not a list.",
362                        key
363                    ));
364                }
365            }
366            println!("Added '{}' to {}", value, key);
367        }
368        ConfigCommands::Remove { key, value } => {
369            match key.as_str() {
370                "locations" | "includes" | "excludes" => {
371                    if let Some(entry) = object.get_mut(&key) {
372                        if let Some(arr) = entry.as_array_mut() {
373                            let val_to_remove = serde_json::Value::String(value.clone());
374                            if let Some(pos) = arr.iter().position(|x| *x == val_to_remove) {
375                                arr.remove(pos);
376                            } else {
377                                return Err(anyhow::anyhow!(
378                                    "Value '{}' not found in {}",
379                                    value,
380                                    key
381                                ));
382                            }
383                        }
384                    } else {
385                        return Err(anyhow::anyhow!("Configuration key '{}' not set.", key));
386                    }
387                }
388                _ => {
389                    return Err(anyhow::anyhow!(
390                        "Cannot remove from configuration key '{}'. It is not a list.",
391                        key
392                    ));
393                }
394            }
395            println!("Removed '{}' from {}", value, key);
396        }
397        _ => unreachable!(), // Get and List are handled above
398    }
399
400    let new_config_str = serde_json::to_string_pretty(&config_json)?;
401    std::fs::write(config_path, new_config_str)?;
402
403    Ok(())
404}
405
406pub fn run() -> Result<()> {
407    ontoenv::api::init_logging();
408    let cmd = Cli::parse();
409    execute(cmd)
410}
411
412pub fn run_from_args<I, T>(args: I) -> Result<()>
413where
414    I: IntoIterator<Item = T>,
415    T: Into<OsString> + Clone,
416{
417    ontoenv::api::init_logging();
418    let cmd = Cli::try_parse_from(args).map_err(Error::from)?;
419    execute(cmd)
420}
421
422fn execute(cmd: Cli) -> Result<()> {
423    // The RUST_LOG env var is set by `init_logging` if ONTOENV_LOG is present.
424    // CLI flags for verbosity take precedence. If nothing is set, we default to "warn".
425    if cmd.debug {
426        std::env::set_var("RUST_LOG", "debug");
427    } else if cmd.verbose {
428        std::env::set_var("RUST_LOG", "info");
429    } else if std::env::var("RUST_LOG").is_err() {
430        // If no CLI flags and no env var is set, default to "warn".
431        std::env::set_var("RUST_LOG", "warn");
432    }
433    let _ = env_logger::try_init();
434
435    let policy = cmd.policy.unwrap_or_else(|| "default".to_string());
436
437    let mut builder = Config::builder()
438        .root(current_dir()?)
439        .require_ontology_names(cmd.require_ontology_names)
440        .strict(cmd.strict)
441        .offline(cmd.offline)
442        .resolution_policy(policy)
443        .temporary(cmd.temporary)
444        .no_search(cmd.no_search);
445
446    // Locations only apply to `init`; other commands ignore positional LOCATIONS
447    if let Commands::Init {
448        locations: Some(locs),
449        ..
450    } = &cmd.command
451    {
452        builder = builder.locations(locs.clone());
453    }
454    // only set includes if they are provided on the command line, otherwise use builder defaults
455    if !cmd.includes.is_empty() {
456        builder = builder.includes(&cmd.includes);
457    }
458    if !cmd.excludes.is_empty() {
459        builder = builder.excludes(&cmd.excludes);
460    }
461    if !cmd.include_ontologies.is_empty() {
462        builder = builder.include_ontologies(&cmd.include_ontologies);
463    }
464    if !cmd.exclude_ontologies.is_empty() {
465        builder = builder.exclude_ontologies(&cmd.exclude_ontologies);
466    }
467
468    let config: Config = builder.build()?;
469
470    if cmd.verbose || cmd.debug {
471        config.print();
472    }
473
474    if let Commands::Reset { force } = &cmd.command {
475        if let Some(root) = ontoenv::api::find_ontoenv_root() {
476            let path = root.join(".ontoenv");
477            println!("Removing .ontoenv directory at {}...", path.display());
478            if !*force {
479                // check delete? [y/N]
480                let mut input = String::new();
481                println!("Are you sure you want to delete the .ontoenv directory? [y/N] ");
482                std::io::stdin()
483                    .read_line(&mut input)
484                    .expect("Failed to read line");
485                let input = input.trim();
486                if input != "y" && input != "Y" {
487                    println!("Aborting...");
488                    return Ok(());
489                }
490            }
491            OntoEnv::reset()?;
492            println!(".ontoenv directory removed.");
493        } else {
494            println!("No .ontoenv directory found. Nothing to do.");
495        }
496        return Ok(());
497    }
498
499    // Discover environment root: ONTOENV_DIR takes precedence, else walk parents
500    let env_dir_var = std::env::var("ONTOENV_DIR").ok().map(PathBuf::from);
501    let discovered_root = if let Some(dir) = env_dir_var.clone() {
502        // If ONTOENV_DIR points to the .ontoenv directory, take its parent as root
503        if dir.file_name().map(|n| n == ".ontoenv").unwrap_or(false) {
504            dir.parent().map(|p| p.to_path_buf())
505        } else {
506            Some(dir)
507        }
508    } else {
509        ontoenv::api::find_ontoenv_root()
510    };
511    let ontoenv_exists = discovered_root
512        .as_ref()
513        .map(|root| root.join(".ontoenv").join("ontoenv.json").exists())
514        .unwrap_or(false);
515    info!("OntoEnv exists: {ontoenv_exists}");
516
517    // create the env object to use in the subcommand.
518    // - if temporary is true, create a new env object each time
519    // - if temporary is false, load the env from the .ontoenv directory if it exists
520    // Determine if this command needs write access to the store
521    let needs_rw = matches!(cmd.command, Commands::Add { .. } | Commands::Update { .. });
522
523    let env: Option<OntoEnv> = if cmd.temporary {
524        // Create a new OntoEnv object in temporary mode
525        let e = OntoEnv::init(config.clone(), false)?;
526        Some(e)
527    } else if cmd.command.to_string() != "Init" && ontoenv_exists {
528        // if .ontoenv exists, load it from discovered root
529        // Open read-only unless the command requires write access
530        Some(OntoEnv::load_from_directory(
531            discovered_root.unwrap(),
532            !needs_rw,
533        )?)
534    } else {
535        None
536    };
537    info!("OntoEnv loaded: {}", env.is_some());
538
539    match cmd.command {
540        Commands::Init { overwrite, .. } => {
541            // if temporary, raise an error
542            if cmd.temporary {
543                return Err(anyhow::anyhow!(
544                    "Cannot initialize in temporary mode. Run `ontoenv init` without --temporary."
545                ));
546            }
547
548            let root = current_dir()?;
549            if root.join(".ontoenv").exists() && !overwrite {
550                println!(
551                    "An ontology environment already exists in: {}",
552                    root.display()
553                );
554                println!("Use --overwrite to re-initialize or `ontoenv update` to update.");
555
556                let env = OntoEnv::load_from_directory(root, false)?;
557                let status = env.status()?;
558                println!("\nCurrent status:");
559                println!("{status}");
560                return Ok(());
561            }
562
563            // The call to `init` will create and update the environment.
564            // `update` will also save it to the directory.
565            let _ = OntoEnv::init(config, overwrite)?;
566        }
567        Commands::Get {
568            ontology,
569            location,
570            output,
571            format,
572        } => {
573            let env = require_ontoenv(env)?;
574
575            // If a location is provided, resolve by location. Otherwise resolve by name (IRI).
576            let graph = if let Some(loc) = location {
577                let oloc = if loc.starts_with("http://") || loc.starts_with("https://") {
578                    OntologyLocation::Url(loc)
579                } else {
580                    // Normalize to absolute path
581                    ontoenv::ontology::OntologyLocation::from_str(&loc)
582                        .unwrap_or_else(|_| OntologyLocation::File(PathBuf::from(loc)))
583                };
584                // Read directly from the specified location to disambiguate
585                oloc.graph()?
586            } else {
587                let iri = NamedNode::new(ontology).map_err(|e| anyhow::anyhow!(e.to_string()))?;
588                let graphid = env
589                    .resolve(ResolveTarget::Graph(iri))
590                    .ok_or(anyhow::anyhow!("Ontology not found"))?;
591                env.get_graph(&graphid)?
592            };
593
594            let fmt = match format
595                .as_deref()
596                .unwrap_or("turtle")
597                .to_ascii_lowercase()
598                .as_str()
599            {
600                "turtle" | "ttl" => RdfFormat::Turtle,
601                "ntriples" | "nt" => RdfFormat::NTriples,
602                "rdfxml" | "xml" => RdfFormat::RdfXml,
603                "jsonld" | "json-ld" => RdfFormat::JsonLd {
604                    profile: JsonLdProfileSet::default(),
605                },
606                other => {
607                    return Err(anyhow::anyhow!(
608                        "Unsupported format '{}'. Use one of: turtle, ntriples, rdfxml, jsonld",
609                        other
610                    ))
611                }
612            };
613
614            if let Some(path) = output {
615                let mut file = std::fs::File::create(path)?;
616                let mut serializer =
617                    oxigraph::io::RdfSerializer::from_format(fmt).for_writer(&mut file);
618                for t in graph.iter() {
619                    serializer.serialize_triple(t)?;
620                }
621                serializer.finish()?;
622            } else {
623                let stdout = std::io::stdout();
624                let mut handle = stdout.lock();
625                let mut serializer =
626                    oxigraph::io::RdfSerializer::from_format(fmt).for_writer(&mut handle);
627                for t in graph.iter() {
628                    serializer.serialize_triple(t)?;
629                }
630                serializer.finish()?;
631            }
632        }
633        Commands::Version => {
634            println!(
635                "ontoenv {} @ {}",
636                env!("CARGO_PKG_VERSION"),
637                env!("GIT_HASH")
638            );
639        }
640        Commands::Status { json } => {
641            let env = require_ontoenv(env)?;
642            if json {
643                // Recompute status details similar to env.status()
644                let ontoenv_dir = current_dir()?.join(".ontoenv");
645                let last_updated = if ontoenv_dir.exists() {
646                    Some(std::fs::metadata(&ontoenv_dir)?.modified()?)
647                        as Option<std::time::SystemTime>
648                } else {
649                    None
650                };
651                let size: u64 = if ontoenv_dir.exists() {
652                    walkdir::WalkDir::new(&ontoenv_dir)
653                        .into_iter()
654                        .filter_map(Result::ok)
655                        .filter(|e| e.file_type().is_file())
656                        .filter_map(|e| e.metadata().ok())
657                        .map(|m| m.len())
658                        .sum()
659                } else {
660                    0
661                };
662                let missing: Vec<String> = env
663                    .missing_imports()
664                    .into_iter()
665                    .map(|n| n.to_uri_string())
666                    .collect();
667                let last_str =
668                    last_updated.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339());
669                let obj = serde_json::json!({
670                    "exists": true,
671                    "num_ontologies": env.ontologies().len(),
672                    "last_updated": last_str,
673                    "store_size_bytes": size,
674                    "missing_imports": missing,
675                });
676                println!("{}", serde_json::to_string_pretty(&obj)?);
677            } else {
678                let status = env.status()?;
679                println!("{status}");
680            }
681        }
682        Commands::Update { quiet, all, json } => {
683            let mut env = require_ontoenv(env)?;
684            let updated = env.update_all(all)?;
685            if json {
686                let arr: Vec<String> = updated.iter().map(|id| id.to_uri_string()).collect();
687                println!("{}", serde_json::to_string_pretty(&arr)?);
688            } else if !quiet {
689                for id in updated {
690                    if let Some(ont) = env.ontologies().get(&id) {
691                        let name = ont.name().to_string();
692                        let loc = ont
693                            .location()
694                            .map(|l| l.to_string())
695                            .unwrap_or_else(|| "N/A".to_string());
696                        println!("{} @ {}", name, loc);
697                    }
698                }
699            }
700            env.save_to_directory()?;
701        }
702        Commands::Closure {
703            ontology,
704            no_rewrite_sh_prefixes,
705            keep_owl_imports,
706            destination,
707            recursion_depth,
708        } => {
709            // make ontology an IRI
710            let iri = NamedNode::new(ontology).map_err(|e| anyhow::anyhow!(e.to_string()))?;
711            let env = require_ontoenv(env)?;
712            let graphid = env
713                .resolve(ResolveTarget::Graph(iri.clone()))
714                .ok_or(anyhow::anyhow!(format!("Ontology {} not found", iri)))?;
715            let closure = env.get_closure(&graphid, recursion_depth)?;
716            // Defaults: rewrite prefixes = ON, remove owl:imports = ON; flags disable these.
717            let rewrite = !no_rewrite_sh_prefixes;
718            let remove = !keep_owl_imports;
719            let union = env.get_union_graph(&closure, Some(rewrite), Some(remove))?;
720            if let Some(failed_imports) = union.failed_imports {
721                for imp in failed_imports {
722                    eprintln!("{imp}");
723                }
724            }
725            // write the graph to a file
726            let destination = destination.unwrap_or_else(|| "output.ttl".to_string());
727            write_dataset_to_file(&union.dataset, &destination)?;
728        }
729        Commands::Add {
730            location,
731            no_imports,
732        } => {
733            let location = if location.starts_with("http") {
734                OntologyLocation::Url(location)
735            } else {
736                OntologyLocation::File(PathBuf::from(location))
737            };
738            let mut env = require_ontoenv(env)?;
739            if no_imports {
740                let _ =
741                    env.add_no_imports(location, Overwrite::Allow, RefreshStrategy::UseCache)?;
742            } else {
743                let _ = env.add(location, Overwrite::Allow, RefreshStrategy::UseCache)?;
744            }
745        }
746        Commands::List { list_cmd, json } => {
747            let env = require_ontoenv(env)?;
748            match list_cmd {
749                ListCommands::Locations => {
750                    let mut locations = env.find_files()?;
751                    locations.sort_by(|a, b| a.as_str().cmp(b.as_str()));
752                    if json {
753                        println!("{}", serde_json::to_string_pretty(&locations)?);
754                    } else {
755                        for loc in locations {
756                            println!("{}", loc);
757                        }
758                    }
759                }
760                ListCommands::Ontologies => {
761                    // print list of ontology URLs from env.ontologies.values() sorted alphabetically
762                    let mut ontologies: Vec<&GraphIdentifier> = env.ontologies().keys().collect();
763                    ontologies.sort_by(|a, b| a.name().cmp(&b.name()));
764                    ontologies.dedup_by(|a, b| a.name() == b.name());
765                    if json {
766                        let out: Vec<String> =
767                            ontologies.into_iter().map(|o| o.to_uri_string()).collect();
768                        println!("{}", serde_json::to_string_pretty(&out)?);
769                    } else {
770                        for ont in ontologies {
771                            println!("{}", ont.to_uri_string());
772                        }
773                    }
774                }
775                ListCommands::Missing => {
776                    let mut missing_imports = env.missing_imports();
777                    missing_imports.sort();
778                    if json {
779                        let out: Vec<String> = missing_imports
780                            .into_iter()
781                            .map(|n| n.to_uri_string())
782                            .collect();
783                        println!("{}", serde_json::to_string_pretty(&out)?);
784                    } else {
785                        for import in missing_imports {
786                            println!("{}", import.to_uri_string());
787                        }
788                    }
789                }
790            }
791        }
792        Commands::Dump { contains } => {
793            let env = require_ontoenv(env)?;
794            env.dump(contains.as_deref());
795        }
796        Commands::DepGraph { roots, output } => {
797            let env = require_ontoenv(env)?;
798            let dot = if let Some(roots) = roots {
799                let roots: Vec<GraphIdentifier> = roots
800                    .iter()
801                    .map(|iri| {
802                        env.resolve(ResolveTarget::Graph(NamedNode::new(iri).unwrap()))
803                            .unwrap()
804                            .clone()
805                    })
806                    .collect();
807                env.rooted_dep_graph_to_dot(roots)?
808            } else {
809                env.dep_graph_to_dot()?
810            };
811            // call graphviz to generate PDF
812            let dot_path = current_dir()?.join("dep_graph.dot");
813            std::fs::write(&dot_path, dot)?;
814            let output_path = output.unwrap_or_else(|| "dep_graph.pdf".to_string());
815            let output = std::process::Command::new("dot")
816                .args(["-Tpdf", dot_path.to_str().unwrap(), "-o", &output_path])
817                .output()?;
818            if !output.status.success() {
819                return Err(anyhow::anyhow!(
820                    "Failed to generate PDF: {}",
821                    String::from_utf8_lossy(&output.stderr)
822                ));
823            }
824        }
825        Commands::Why { ontologies, json } => {
826            let env = require_ontoenv(env)?;
827            if json {
828                let mut all: BTreeMap<String, Vec<Vec<String>>> = BTreeMap::new();
829                for ont in ontologies {
830                    let iri = NamedNode::new(ont).map_err(|e| anyhow::anyhow!(e.to_string()))?;
831                    let (paths, missing) = match env.explain_import(&iri)? {
832                        ontoenv::api::ImportPaths::Present(paths) => (paths, false),
833                        ontoenv::api::ImportPaths::Missing { importers } => (importers, true),
834                    };
835                    let formatted = format_import_paths(&iri, paths, missing);
836                    all.insert(iri.to_uri_string(), formatted);
837                }
838                println!("{}", serde_json::to_string_pretty(&all)?);
839            } else {
840                for ont in ontologies {
841                    let iri = NamedNode::new(ont).map_err(|e| anyhow::anyhow!(e.to_string()))?;
842                    match env.explain_import(&iri)? {
843                        ontoenv::api::ImportPaths::Present(paths) => {
844                            print_import_paths(&iri, paths, false);
845                        }
846                        ontoenv::api::ImportPaths::Missing { importers } => {
847                            print_import_paths(&iri, importers, true);
848                        }
849                    }
850                }
851            }
852        }
853        Commands::Doctor { json } => {
854            let env = require_ontoenv(env)?;
855            let problems = env.doctor()?;
856            if json {
857                let out: Vec<serde_json::Value> = problems
858                    .into_iter()
859                    .map(|p| serde_json::json!({
860                        "message": p.message,
861                        "locations": p.locations.into_iter().map(|loc| loc.to_string()).collect::<Vec<_>>()
862                    }))
863                    .collect();
864                println!("{}", serde_json::to_string_pretty(&out)?);
865            } else if problems.is_empty() {
866                println!("No issues found.");
867            } else {
868                println!("Found {} issues:", problems.len());
869                for problem in problems {
870                    println!("- {}", problem.message);
871                    for location in problem.locations {
872                        println!("  - {location}");
873                    }
874                }
875            }
876        }
877        Commands::Config(config_cmd) => {
878            handle_config_command(config_cmd, cmd.temporary)?;
879        }
880        Commands::Reset { .. } => {
881            // This command is handled before the environment is loaded.
882        }
883    }
884
885    Ok(())
886}
887
888fn require_ontoenv(env: Option<OntoEnv>) -> Result<OntoEnv> {
889    env.ok_or_else(|| {
890        anyhow::anyhow!("OntoEnv not found. Run `ontoenv init` to create a new OntoEnv or use -t/--temporary to use a temporary environment.")
891    })
892}
893
894fn format_import_paths(
895    target: &NamedNode,
896    paths: Vec<Vec<GraphIdentifier>>,
897    missing: bool,
898) -> Vec<Vec<String>> {
899    let mut unique: BTreeSet<Vec<String>> = BTreeSet::new();
900    if paths.is_empty() {
901        if missing {
902            unique.insert(vec![format!("{} (missing)", target.to_uri_string())]);
903        }
904        return unique.into_iter().collect();
905    }
906    for path in paths {
907        let mut entries: Vec<String> = path.into_iter().map(|id| id.to_uri_string()).collect();
908        if missing {
909            entries.push(format!("{} (missing)", target.to_uri_string()));
910        }
911        unique.insert(entries);
912    }
913    unique.into_iter().collect()
914}
915
916fn print_import_paths(target: &NamedNode, paths: Vec<Vec<GraphIdentifier>>, missing: bool) {
917    if paths.is_empty() {
918        if missing {
919            println!(
920                "Ontology {} is missing but no importers reference it.",
921                target.to_uri_string()
922            );
923        } else {
924            println!("No importers found for {}", target.to_uri_string());
925        }
926        return;
927    }
928
929    println!(
930        "Why {}{}:",
931        target.to_uri_string(),
932        if missing { " (missing)" } else { "" }
933    );
934
935    let mut lines: BTreeSet<String> = BTreeSet::new();
936    for path in paths {
937        let mut segments: Vec<String> = path.into_iter().map(|id| id.to_uri_string()).collect();
938        if missing {
939            segments.push(format!("{} (missing)", target.to_uri_string()));
940        }
941        lines.insert(segments.join(" -> "));
942    }
943
944    for line in lines {
945        println!("{}", line);
946    }
947}