//! OpenViking CLI Sidecar Integration //! //! Wraps the OpenViking Rust CLI (`ov`) as a Tauri sidecar for local memory operations. //! This eliminates the need for a Python server dependency. //! //! Reference: https://github.com/volcengine/OpenViking use serde::{Deserialize, Serialize}; use std::process::Command; // === Types === #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VikingStatus { pub available: bool, pub version: Option, pub data_dir: Option, pub error: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VikingResource { pub uri: String, pub name: String, #[serde(rename = "type")] pub resource_type: String, pub size: Option, pub modified_at: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VikingFindResult { pub uri: String, pub score: f64, pub content: String, pub level: String, pub overview: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VikingGrepResult { pub uri: String, pub line: u32, pub content: String, pub match_start: u32, pub match_end: u32, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VikingAddResult { pub uri: String, pub status: String, } // === CLI Path Resolution === fn get_viking_cli_path() -> Result { // Try environment variable first if let Ok(path) = std::env::var("ZCLAW_VIKING_BIN") { if std::path::Path::new(&path).exists() { return Ok(path); } } // Try bundled sidecar location let binary_name = if cfg!(target_os = "windows") { "ov-x86_64-pc-windows-msvc.exe" } else if cfg!(target_os = "macos") { if cfg!(target_arch = "aarch64") { "ov-aarch64-apple-darwin" } else { "ov-x86_64-apple-darwin" } } else { "ov-x86_64-unknown-linux-gnu" }; // Check common locations let locations = vec![ format!("./binaries/{}", binary_name), format!("./resources/viking/{}", binary_name), format!("./{}", binary_name), ]; for loc in locations { if std::path::Path::new(&loc).exists() { return Ok(loc); } } // Fallback to system PATH Ok("ov".to_string()) } fn run_viking_cli(args: &[&str]) -> Result { let cli_path = get_viking_cli_path()?; let output = Command::new(&cli_path) .args(args) .output() .map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { format!( "OpenViking CLI not found. Please install 'ov' or set ZCLAW_VIKING_BIN. Tried: {}", cli_path ) } else { format!("Failed to run OpenViking CLI: {}", e) } })?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !stderr.is_empty() { Err(stderr) } else if !stdout.is_empty() { Err(stdout) } else { Err(format!("OpenViking CLI failed with status: {}", output.status)) } } } /// Helper function to run Viking CLI and parse JSON output /// Reserved for future JSON-based commands #[allow(dead_code)] fn run_viking_cli_json Deserialize<'de>>(args: &[&str]) -> Result { let output = run_viking_cli(args)?; // Handle empty output if output.is_empty() { return Err("OpenViking CLI returned empty output".to_string()); } // Try to parse as JSON serde_json::from_str(&output) .map_err(|e| format!("Failed to parse OpenViking output as JSON: {}\nOutput: {}", e, output)) } // === Tauri Commands === /// Check if OpenViking CLI is available #[tauri::command] pub fn viking_status() -> Result { let result = run_viking_cli(&["--version"]); match result { Ok(version_output) => { // Parse version from output like "ov 0.1.0" let version = version_output .lines() .next() .map(|s| s.trim().to_string()); Ok(VikingStatus { available: true, version, data_dir: None, // TODO: Get from CLI error: None, }) } Err(e) => Ok(VikingStatus { available: false, version: None, data_dir: None, error: Some(e), }), } } /// Add a resource to OpenViking #[tauri::command] pub fn viking_add(uri: String, content: String) -> Result { // Create a temporary file for the content let temp_dir = std::env::temp_dir(); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); let temp_file = temp_dir.join(format!("viking_add_{}.txt", timestamp)); std::fs::write(&temp_file, &content) .map_err(|e| format!("Failed to write temp file: {}", e))?; let temp_path = temp_file.to_string_lossy(); let result = run_viking_cli(&["add", &uri, "--file", &temp_path]); // Clean up temp file let _ = std::fs::remove_file(&temp_file); match result { Ok(_) => Ok(VikingAddResult { uri, status: "added".to_string(), }), Err(e) => Err(e), } } /// Add a resource with inline content (for small content) #[tauri::command] pub fn viking_add_inline(uri: String, content: String) -> Result { // Use stdin for content let cli_path = get_viking_cli_path()?; let output = Command::new(&cli_path) .args(["add", &uri]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| format!("Failed to spawn OpenViking CLI: {}", e))?; // Write content to stdin if let Some(mut stdin) = output.stdin.as_ref() { use std::io::Write; stdin.write_all(content.as_bytes()) .map_err(|e| format!("Failed to write to stdin: {}", e))?; } let result = output.wait_with_output() .map_err(|e| format!("Failed to read output: {}", e))?; if result.status.success() { Ok(VikingAddResult { uri, status: "added".to_string(), }) } else { let stderr = String::from_utf8_lossy(&result.stderr).trim().to_string(); Err(if !stderr.is_empty() { stderr } else { "Failed to add resource".to_string() }) } } /// Find resources by semantic search #[tauri::command] pub fn viking_find( query: String, scope: Option, limit: Option, ) -> Result, String> { let mut args = vec!["find", "--json", &query]; let scope_arg; if let Some(ref s) = scope { scope_arg = format!("--scope={}", s); args.push(&scope_arg); } let limit_arg; if let Some(l) = limit { limit_arg = format!("--limit={}", l); args.push(&limit_arg); } // CLI returns JSON array directly let output = run_viking_cli(&args)?; // Handle empty or null results if output.is_empty() || output == "null" || output == "[]" { return Ok(Vec::new()); } serde_json::from_str(&output) .map_err(|e| format!("Failed to parse find results: {}\nOutput: {}", e, output)) } /// Grep resources by pattern #[tauri::command] pub fn viking_grep( pattern: String, uri: Option, case_sensitive: Option, limit: Option, ) -> Result, String> { let mut args = vec!["grep", "--json", &pattern]; let uri_arg; if let Some(ref u) = uri { uri_arg = format!("--uri={}", u); args.push(&uri_arg); } if case_sensitive.unwrap_or(false) { args.push("--case-sensitive"); } let limit_arg; if let Some(l) = limit { limit_arg = format!("--limit={}", l); args.push(&limit_arg); } let output = run_viking_cli(&args)?; if output.is_empty() || output == "null" || output == "[]" { return Ok(Vec::new()); } serde_json::from_str(&output) .map_err(|e| format!("Failed to parse grep results: {}\nOutput: {}", e, output)) } /// List resources at a path #[tauri::command] pub fn viking_ls(path: String) -> Result, String> { let output = run_viking_cli(&["ls", "--json", &path])?; if output.is_empty() || output == "null" || output == "[]" { return Ok(Vec::new()); } serde_json::from_str(&output) .map_err(|e| format!("Failed to parse ls results: {}\nOutput: {}", e, output)) } /// Read resource content #[tauri::command] pub fn viking_read(uri: String, level: Option) -> Result { let level_val = level.unwrap_or_else(|| "L1".to_string()); let level_arg = format!("--level={}", level_val); run_viking_cli(&["read", &uri, &level_arg]) } /// Remove a resource #[tauri::command] pub fn viking_remove(uri: String) -> Result<(), String> { run_viking_cli(&["remove", &uri])?; Ok(()) } /// Get resource tree #[tauri::command] pub fn viking_tree(path: String, depth: Option) -> Result { let depth_val = depth.unwrap_or(2); let depth_arg = format!("--depth={}", depth_val); let output = run_viking_cli(&["tree", "--json", &path, &depth_arg])?; if output.is_empty() || output == "null" { return Ok(serde_json::json!({})); } serde_json::from_str(&output) .map_err(|e| format!("Failed to parse tree result: {}\nOutput: {}", e, output)) } // === Tests === #[cfg(test)] mod tests { use super::*; #[test] fn test_status_unavailable_without_cli() { // This test will fail if ov is installed, which is fine let result = viking_status(); assert!(result.is_ok()); } }