use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RobotResponse<T: Serialize> {
pub success: bool,
pub meta: ResponseMeta,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<RobotError>,
}
impl<T: Serialize> RobotResponse<T> {
pub fn success(data: T, meta: ResponseMeta) -> Self {
Self {
success: true,
meta,
data: Some(data),
errors: vec![],
}
}
pub fn error(errors: Vec<RobotError>, meta: ResponseMeta) -> RobotResponse<()> {
RobotResponse {
success: false,
meta,
data: None,
errors,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseMeta {
pub command: String,
pub elapsed_ms: u64,
pub timestamp: DateTime<Utc>,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_corrected: Option<AutoCorrection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pagination: Option<Pagination>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_budget: Option<TokenBudget>,
}
impl ResponseMeta {
pub fn new(command: impl Into<String>) -> Self {
Self {
command: command.into(),
elapsed_ms: 0,
timestamp: Utc::now(),
version: env!("CARGO_PKG_VERSION").to_string(),
query: None,
role: None,
auto_corrected: None,
pagination: None,
token_budget: None,
}
}
pub fn with_elapsed(mut self, elapsed_ms: u64) -> Self {
self.elapsed_ms = elapsed_ms;
self
}
pub fn with_query(mut self, query: impl Into<String>) -> Self {
self.query = Some(query.into());
self
}
pub fn with_role(mut self, role: impl Into<String>) -> Self {
self.role = Some(role.into());
self
}
pub fn with_auto_correction(mut self, auto_corrected: AutoCorrection) -> Self {
self.auto_corrected = Some(auto_corrected);
self
}
pub fn with_pagination(mut self, pagination: Pagination) -> Self {
self.pagination = Some(pagination);
self
}
pub fn with_token_budget(mut self, token_budget: TokenBudget) -> Self {
self.token_budget = Some(token_budget);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoCorrection {
pub original: String,
pub corrected: String,
pub distance: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pagination {
pub total: usize,
pub returned: usize,
pub offset: usize,
pub has_more: bool,
}
impl Pagination {
pub fn new(total: usize, returned: usize, offset: usize) -> Self {
Self {
total,
returned,
offset,
has_more: offset + returned < total,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenBudget {
pub max_tokens: usize,
pub estimated_tokens: usize,
pub truncated: bool,
}
impl TokenBudget {
pub fn new(max_tokens: usize) -> Self {
Self {
max_tokens,
estimated_tokens: 0,
truncated: false,
}
}
pub fn with_estimate(mut self, estimated_tokens: usize) -> Self {
self.estimated_tokens = estimated_tokens;
self.truncated = estimated_tokens >= self.max_tokens;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RobotError {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
}
impl RobotError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
details: None,
suggestion: None,
}
}
pub fn with_details(mut self, details: impl Into<String>) -> Self {
self.details = Some(details.into());
self
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
pub fn unknown_command(command: &str, suggestions: &[String]) -> Self {
let mut err = Self::new("E001", format!("Unknown command: {}", command));
if !suggestions.is_empty() {
err = err.with_suggestion(format!("Did you mean: {}?", suggestions.join(", ")));
}
err
}
pub fn invalid_argument(arg: &str, reason: &str) -> Self {
Self::new("E002", format!("Invalid argument '{}': {}", arg, reason))
}
pub fn missing_argument(arg: &str) -> Self {
Self::new("E003", format!("Missing required argument: {}", arg))
.with_suggestion(format!("Provide the {} argument", arg))
}
pub fn index_not_found(index_name: &str) -> Self {
Self::new("E004", format!("Index not found: {}", index_name))
.with_suggestion("Initialize the index first")
}
pub fn no_results(query: &str) -> Self {
Self::new("E005", format!("No results found for: {}", query))
.with_suggestion("Try a broader search query")
}
pub fn network_error(message: &str) -> Self {
Self::new("E006", format!("Network error: {}", message))
}
pub fn timeout_error(operation: &str, timeout_ms: u64) -> Self {
Self::new(
"E007",
format!("Operation '{}' timed out after {}ms", operation, timeout_ms),
)
}
pub fn parse_error(message: &str) -> Self {
Self::new("E008", format!("Parse error: {}", message))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResultsData {
pub results: Vec<SearchResultItem>,
pub total_matches: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub concepts_matched: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub thesaurus_matched: Vec<String>,
#[serde(default)]
pub wildcard_fallback: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResultItem {
pub rank: usize,
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub score: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub preview_truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilitiesData {
pub name: String,
pub version: String,
pub description: String,
pub features: FeatureFlags,
pub commands: Vec<String>,
pub supported_formats: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub index_status: Option<IndexStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureFlags {
pub search: bool,
pub chat: bool,
pub mcp_tools: bool,
pub file_operations: bool,
pub web_operations: bool,
pub vm_execution: bool,
pub session_search: bool,
pub knowledge_graph: bool,
}
impl Default for FeatureFlags {
fn default() -> Self {
Self {
search: true,
chat: cfg!(feature = "repl-chat"),
mcp_tools: cfg!(feature = "repl-mcp"),
file_operations: cfg!(feature = "repl-file"),
web_operations: cfg!(feature = "repl-web"),
vm_execution: true,
session_search: cfg!(feature = "repl-sessions"),
knowledge_graph: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexStatus {
pub documents_indexed: usize,
pub sessions_indexed: usize,
pub last_updated: Option<DateTime<Utc>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_robot_response_success() {
let meta = ResponseMeta::new("search");
let response = RobotResponse::success("test data", meta);
assert!(response.success);
assert!(response.data.is_some());
assert!(response.errors.is_empty());
}
#[test]
fn test_robot_response_error() {
let meta = ResponseMeta::new("search");
let errors = vec![RobotError::no_results("test query")];
let response = RobotResponse::<()>::error(errors, meta);
assert!(!response.success);
assert!(response.data.is_none());
assert!(!response.errors.is_empty());
}
#[test]
fn test_pagination() {
let pagination = Pagination::new(100, 10, 0);
assert!(pagination.has_more);
let pagination = Pagination::new(100, 10, 90);
assert!(!pagination.has_more);
}
#[test]
fn test_robot_error_serialization() {
let error = RobotError::unknown_command("serach", &["search".to_string()]);
let json = serde_json::to_string(&error).unwrap();
assert!(json.contains("E001"));
assert!(json.contains("serach"));
}
#[test]
fn test_meta_with_auto_correction() {
let meta = ResponseMeta::new("search").with_auto_correction(AutoCorrection {
original: "serach".to_string(),
corrected: "search".to_string(),
distance: 2,
});
assert!(meta.auto_corrected.is_some());
let ac = meta.auto_corrected.unwrap();
assert_eq!(ac.original, "serach");
assert_eq!(ac.corrected, "search");
assert_eq!(ac.distance, 2);
}
#[test]
fn test_meta_with_pagination() {
let meta = ResponseMeta::new("search").with_pagination(Pagination::new(100, 10, 0));
assert!(meta.pagination.is_some());
}
#[test]
fn test_search_result_item_serialization_roundtrip() {
let item = SearchResultItem {
rank: 1,
id: "doc-1".to_string(),
title: "Test Document".to_string(),
url: Some("https://example.com".to_string()),
score: 0.95,
preview: Some("A test document".to_string()),
source: Some("test".to_string()),
date: None,
preview_truncated: false,
};
let json = serde_json::to_string(&item).unwrap();
let deserialized: SearchResultItem = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, "doc-1");
assert_eq!(deserialized.title, "Test Document");
assert_eq!(deserialized.rank, 1);
}
#[test]
fn test_preview_truncated_skip_serializing_false() {
let item = SearchResultItem {
rank: 1,
id: "doc-1".to_string(),
title: "Test".to_string(),
url: None,
score: 0.5,
preview: None,
source: None,
date: None,
preview_truncated: false,
};
let json = serde_json::to_string(&item).unwrap();
assert!(!json.contains("preview_truncated"));
}
#[test]
fn test_preview_truncated_included_when_true() {
let item = SearchResultItem {
rank: 1,
id: "doc-1".to_string(),
title: "Test".to_string(),
url: None,
score: 0.5,
preview: None,
source: None,
date: None,
preview_truncated: true,
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("preview_truncated"));
}
#[test]
fn test_token_budget_serialization() {
let budget = TokenBudget::new(1000);
let json = serde_json::to_string(&budget).unwrap();
let deserialized: TokenBudget = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.max_tokens, 1000);
assert!(!deserialized.truncated);
assert_eq!(deserialized.estimated_tokens, 0);
}
#[test]
fn test_robot_error_no_results() {
let error = RobotError::no_results("missing query");
let json = serde_json::to_string(&error).unwrap();
assert!(json.contains("missing query"));
}
#[test]
fn test_meta_with_query_and_role() {
let meta = ResponseMeta::new("search")
.with_query("rust programming")
.with_role("developer");
assert_eq!(meta.query.as_deref(), Some("rust programming"));
assert_eq!(meta.role.as_deref(), Some("developer"));
}
#[test]
fn test_meta_serialization_omits_none() {
let meta = ResponseMeta::new("search");
let json = serde_json::to_string(&meta).unwrap();
assert!(!json.contains("\"query\""));
assert!(!json.contains("\"role\""));
}
#[test]
fn test_meta_serialization_includes_query_role() {
let meta = ResponseMeta::new("search")
.with_query("test query")
.with_role("engineer");
let json = serde_json::to_string(&meta).unwrap();
assert!(json.contains("\"query\""));
assert!(json.contains("\"role\""));
assert!(json.contains("test query"));
assert!(json.contains("engineer"));
}
}