409 lines
13 KiB
Rust
409 lines
13 KiB
Rust
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<String>,
|
|
|
|
/// MIME type
|
|
#[arg(long)]
|
|
mime: Option<String>,
|
|
|
|
/// Title
|
|
#[arg(long)]
|
|
title: Option<String>,
|
|
},
|
|
|
|
/// Get an object
|
|
Get {
|
|
/// Object path (namespace/name or namespace/id)
|
|
path: String,
|
|
|
|
/// Output file (default: stdout)
|
|
#[arg(long)]
|
|
output: Option<PathBuf>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// Namespace to search
|
|
#[arg(long)]
|
|
ns: String,
|
|
|
|
/// Filters (key=value pairs, comma-separated)
|
|
#[arg(long)]
|
|
filter: Option<String>,
|
|
|
|
/// 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<String>,
|
|
},
|
|
}
|
|
|
|
#[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<String>,
|
|
mime: Option<String>,
|
|
title: Option<String>,
|
|
) -> 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<PathBuf>, 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<String>,
|
|
ns: String,
|
|
filter: Option<String>,
|
|
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<String>) -> 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<BTreeMap<String, String>> {
|
|
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)
|
|
}
|