first commit

This commit is contained in:
Timur Gordon
2025-10-20 22:24:25 +02:00
commit 097360ad12
48 changed files with 6712 additions and 0 deletions

408
src/interfaces/cli.rs Normal file
View File

@@ -0,0 +1,408 @@
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)
}

3
src/interfaces/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod cli;
pub use cli::Cli;