use crate::config::{self, NamespaceConfig}; use crate::error::{Error, Result}; use crate::index::FieldIndex; use crate::retrieve::{RetrievalQuery, SearchEngine}; use crate::store::{HeroDbClient, OsirisObject}; use clap::{Parser, Subcommand}; use std::collections::BTreeMap; use std::fs; use std::io::{self, Read}; use std::path::PathBuf; #[derive(Parser, Debug)] #[command(name = "osiris")] #[command(about = "OSIRIS - Object Storage, Indexing & Retrieval Intelligent System", long_about = None)] pub struct Cli { #[command(subcommand)] pub command: Commands, } #[derive(Subcommand, Debug)] pub enum Commands { /// Initialize OSIRIS configuration Init { /// HeroDB URL #[arg(long, default_value = "redis://localhost:6379")] herodb: String, }, /// Namespace management Ns { #[command(subcommand)] command: NsCommands, }, /// Put an object Put { /// Object path (namespace/name) path: String, /// File to upload (use '-' for stdin) file: String, /// Tags (key=value pairs, comma-separated) #[arg(long)] tags: Option, /// MIME type #[arg(long)] mime: Option, /// Title #[arg(long)] title: Option, }, /// Get an object Get { /// Object path (namespace/name or namespace/id) path: String, /// Output file (default: stdout) #[arg(long)] output: Option, /// Output raw content only (no metadata) #[arg(long)] raw: bool, }, /// Delete an object Del { /// Object path (namespace/name or namespace/id) path: String, }, /// Search/find objects Find { /// Text query (optional) query: Option, /// Namespace to search #[arg(long)] ns: String, /// Filters (key=value pairs, comma-separated) #[arg(long)] filter: Option, /// Maximum number of results #[arg(long, default_value = "10")] topk: usize, /// Output as JSON #[arg(long)] json: bool, }, /// Show statistics Stats { /// Namespace (optional, shows all if not specified) #[arg(long)] ns: Option, }, } #[derive(Subcommand, Debug, Clone)] pub enum NsCommands { /// Create a new namespace Create { /// Namespace name name: String, }, /// List all namespaces List, /// Delete a namespace Delete { /// Namespace name name: String, }, } impl Cli { pub async fn run(self) -> Result<()> { match self.command { Commands::Init { herodb } => { let config = config::create_default_config(herodb); config::save_config(&config, None)?; println!("✓ OSIRIS initialized"); println!(" Config: {}", config::default_config_path().display()); Ok(()) } Commands::Ns { ref command } => self.handle_ns_command(command.clone()).await, Commands::Put { ref path, ref file, ref tags, ref mime, ref title } => { self.handle_put(path.clone(), file.clone(), tags.clone(), mime.clone(), title.clone()).await } Commands::Get { ref path, ref output, raw } => { self.handle_get(path.clone(), output.clone(), raw).await } Commands::Del { ref path } => self.handle_del(path.clone()).await, Commands::Find { ref query, ref ns, ref filter, topk, json } => { self.handle_find(query.clone(), ns.clone(), filter.clone(), topk, json).await } Commands::Stats { ref ns } => self.handle_stats(ns.clone()).await, } } async fn handle_ns_command(&self, command: NsCommands) -> Result<()> { let mut config = config::load_config(None)?; match command { NsCommands::Create { name } => { if config.get_namespace(&name).is_some() { return Err(Error::InvalidInput(format!( "Namespace '{}' already exists", name ))); } let db_id = config.next_db_id(); let ns_config = NamespaceConfig { db_id }; config.set_namespace(name.clone(), ns_config); config::save_config(&config, None)?; println!("✓ Created namespace '{}' (DB {})", name, db_id); Ok(()) } NsCommands::List => { if config.namespaces.is_empty() { println!("No namespaces configured"); } else { println!("Namespaces:"); for (name, ns_config) in &config.namespaces { println!(" {} → DB {}", name, ns_config.db_id); } } Ok(()) } NsCommands::Delete { name } => { if config.remove_namespace(&name).is_none() { return Err(Error::NotFound(format!("Namespace '{}'", name))); } config::save_config(&config, None)?; println!("✓ Deleted namespace '{}'", name); Ok(()) } } } async fn handle_put( &self, path: String, file: String, tags: Option, mime: Option, title: Option, ) -> Result<()> { let (ns, name) = parse_path(&path)?; let config = config::load_config(None)?; let ns_config = config.get_namespace(&ns) .ok_or_else(|| Error::NotFound(format!("Namespace '{}'", ns)))?; // Read content let content = if file == "-" { let mut buffer = String::new(); io::stdin().read_to_string(&mut buffer)?; buffer } else { fs::read_to_string(&file)? }; // Create object let mut obj = OsirisObject::with_id(name.clone(), ns.clone(), Some(content)); if let Some(title) = title { obj.set_title(Some(title)); } if let Some(mime) = mime { obj.set_mime(Some(mime)); } // Parse tags if let Some(tags_str) = tags { let tag_map = parse_tags(&tags_str)?; for (key, value) in tag_map { obj.set_tag(key, value); } } // Store object let client = HeroDbClient::new(&config.herodb.url, ns_config.db_id)?; let index = FieldIndex::new(client.clone()); client.put_object(&obj).await?; index.index_object(&obj).await?; println!("✓ Stored {}/{}", ns, obj.id); Ok(()) } async fn handle_get(&self, path: String, output: Option, raw: bool) -> Result<()> { let (ns, id) = parse_path(&path)?; let config = config::load_config(None)?; let ns_config = config.get_namespace(&ns) .ok_or_else(|| Error::NotFound(format!("Namespace '{}'", ns)))?; let client = HeroDbClient::new(&config.herodb.url, ns_config.db_id)?; let obj = client.get_object(&id).await?; if raw { // Output raw content only let content = obj.text.unwrap_or_default(); if let Some(output_path) = output { fs::write(output_path, content)?; } else { print!("{}", content); } } else { // Output full object as JSON let json = serde_json::to_string_pretty(&obj)?; if let Some(output_path) = output { fs::write(output_path, json)?; } else { println!("{}", json); } } Ok(()) } async fn handle_del(&self, path: String) -> Result<()> { let (ns, id) = parse_path(&path)?; let config = config::load_config(None)?; let ns_config = config.get_namespace(&ns) .ok_or_else(|| Error::NotFound(format!("Namespace '{}'", ns)))?; let client = HeroDbClient::new(&config.herodb.url, ns_config.db_id)?; let index = FieldIndex::new(client.clone()); // Get object first to deindex it let obj = client.get_object(&id).await?; index.deindex_object(&obj).await?; let deleted = client.delete_object(&id).await?; if deleted { println!("✓ Deleted {}/{}", ns, id); Ok(()) } else { Err(Error::NotFound(format!("{}/{}", ns, id))) } } async fn handle_find( &self, query: Option, ns: String, filter: Option, topk: usize, json: bool, ) -> Result<()> { let config = config::load_config(None)?; let ns_config = config.get_namespace(&ns) .ok_or_else(|| Error::NotFound(format!("Namespace '{}'", ns)))?; let client = HeroDbClient::new(&config.herodb.url, ns_config.db_id)?; let engine = SearchEngine::new(client.clone()); // Build query let mut retrieval_query = RetrievalQuery::new(ns.clone()).with_top_k(topk); if let Some(text) = query { retrieval_query = retrieval_query.with_text(text); } if let Some(filter_str) = filter { let filters = parse_tags(&filter_str)?; for (key, value) in filters { retrieval_query = retrieval_query.with_filter(key, value); } } // Execute search let results = engine.search(&retrieval_query).await?; if json { println!("{}", serde_json::to_string_pretty(&results)?); } else { if results.is_empty() { println!("No results found"); } else { println!("Found {} result(s):\n", results.len()); for (i, result) in results.iter().enumerate() { println!("{}. {} (score: {:.2})", i + 1, result.id, result.score); if let Some(snippet) = &result.snippet { println!(" {}", snippet); } println!(); } } } Ok(()) } async fn handle_stats(&self, ns: Option) -> Result<()> { let config = config::load_config(None)?; if let Some(ns_name) = ns { let ns_config = config.get_namespace(&ns_name) .ok_or_else(|| Error::NotFound(format!("Namespace '{}'", ns_name)))?; let client = HeroDbClient::new(&config.herodb.url, ns_config.db_id)?; let size = client.dbsize().await?; println!("Namespace: {}", ns_name); println!(" DB ID: {}", ns_config.db_id); println!(" Keys: {}", size); } else { println!("OSIRIS Statistics\n"); println!("Namespaces: {}", config.namespaces.len()); for (name, ns_config) in &config.namespaces { let client = HeroDbClient::new(&config.herodb.url, ns_config.db_id)?; let size = client.dbsize().await?; println!(" {} (DB {}) → {} keys", name, ns_config.db_id, size); } } Ok(()) } } /// Parse a path into namespace and name/id fn parse_path(path: &str) -> Result<(String, String)> { let parts: Vec<&str> = path.splitn(2, '/').collect(); if parts.len() != 2 { return Err(Error::InvalidInput(format!( "Invalid path format. Expected 'namespace/name', got '{}'", path ))); } Ok((parts[0].to_string(), parts[1].to_string())) } /// Parse tags from comma-separated key=value pairs fn parse_tags(tags_str: &str) -> Result> { let mut tags = BTreeMap::new(); for pair in tags_str.split(',') { let parts: Vec<&str> = pair.trim().splitn(2, '=').collect(); if parts.len() != 2 { return Err(Error::InvalidInput(format!( "Invalid tag format. Expected 'key=value', got '{}'", pair ))); } tags.insert(parts[0].to_string(), parts[1].to_string()); } Ok(tags) }