use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::app::cli::IndexReport;
use crate::domain::corpus::sandbox::{FileEntry, TreeNode};
use crate::domain::ground::GroundResponse;
pub use crate::domain::corpus::{LineRange, Position};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonRequest {
pub cwd: PathBuf,
pub payload: DaemonRequestPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum DaemonRequestPayload {
Ping,
Ground(GroundRequest),
Index(IndexRequest),
ListCorpora,
ListFiles(ListFilesRequest),
ListTree(ListTreeRequest),
AddMarkdown(AddMarkdownRequest),
ReadMarkdown(ReadMarkdownRequest),
DeleteMarkdown(DeleteMarkdownRequest),
CorpusStats { corpus: Option<String> },
Shutdown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroundRequest {
pub query: String,
pub corpus: Option<String>,
pub top_files: Option<usize>,
pub chunks_per_file: Option<usize>,
pub limit: Option<usize>,
pub snippet_chars: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexRequest {
pub corpus: Option<String>,
pub paths_from: Option<PathBuf>,
#[serde(default)]
pub strict: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListFilesRequest {
pub corpus: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListTreeRequest {
pub corpus: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AddMarkdownRequest {
pub corpus: String,
pub path: String,
pub content: String,
#[serde(default)]
pub overwrite: bool,
#[serde(default)]
pub under_heading: Option<String>,
#[serde(default)]
pub position: Position,
#[serde(default)]
pub replace_lines: Option<LineRange>,
#[serde(default)]
pub replace_match: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadMarkdownRequest {
pub corpus: Option<String>,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteMarkdownRequest {
pub corpus: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum DaemonResponse {
Ok { result: serde_json::Value },
Err { kind: ErrorKind, message: String },
}
impl DaemonResponse {
pub fn ok<T: Serialize>(value: &T) -> Self {
match serde_json::to_value(value) {
Ok(result) => DaemonResponse::Ok { result },
Err(e) => DaemonResponse::internal(format!("serialize response: {e}")),
}
}
pub fn invalid_params(msg: impl Into<String>) -> Self {
DaemonResponse::Err {
kind: ErrorKind::InvalidParams,
message: msg.into(),
}
}
pub fn internal(msg: impl Into<String>) -> Self {
DaemonResponse::Err {
kind: ErrorKind::Internal,
message: msg.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
InvalidParams,
Internal,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CorpusEntry {
pub name: String,
pub paths: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroundResult {
pub outline: String,
pub response: GroundResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddMarkdownResult {
pub corpus: String,
pub path: String,
pub absolute_path: String,
pub indexed: IndexReport,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadMarkdownResult {
pub corpus: String,
pub path: String,
pub absolute_path: String,
pub content: String,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteMarkdownResult {
pub corpus: String,
pub path: String,
pub absolute_path: String,
pub file_ref: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorpusStatsResult {
pub corpus: String,
pub indexed_files: u64,
pub total_chunks: u64,
pub last_indexed_ms: Option<i64>,
pub unindexed_files: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PongResult {
pub version: String,
}
pub type ListFilesResult = Vec<FileEntry>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListTreeResult {
pub corpus: String,
pub root: TreeNode,
}
pub type ListCorporaResult = Vec<CorpusEntry>;
#[cfg(test)]
mod tests {
use super::*;
struct FailsToSerialize;
impl Serialize for FailsToSerialize {
fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("boom"))
}
}
#[test]
fn ok_maps_serialize_failure_to_internal_error_not_null_success() {
let resp = DaemonResponse::ok(&FailsToSerialize);
match resp {
DaemonResponse::Err { kind, message } => {
assert_eq!(kind, ErrorKind::Internal);
assert!(
message.starts_with("serialize response:"),
"message should name the serialize failure, got: {message:?}"
);
}
DaemonResponse::Ok { result } => {
panic!("serialize failure must not produce Ok, got result: {result:?}");
}
}
}
#[test]
fn ok_wraps_serializable_payload_verbatim() {
let resp = DaemonResponse::ok(&"pong");
match resp {
DaemonResponse::Ok { result } => {
assert_eq!(result, serde_json::Value::String("pong".to_string()));
}
DaemonResponse::Err { kind, message } => {
panic!("clean payload must serialize, got {kind:?}: {message}");
}
}
}
#[test]
fn serialize_failure_response_is_err_envelope_on_the_wire() {
let wire = serde_json::to_value(DaemonResponse::ok(&FailsToSerialize))
.expect("the Err envelope itself serializes cleanly");
assert_eq!(wire["status"], "err");
assert_eq!(wire["kind"], "internal");
assert!(
wire.get("result").is_none(),
"an error envelope must not carry a `result` field, got: {wire}"
);
assert!(
wire["message"]
.as_str()
.is_some_and(|m| m.starts_with("serialize response:")),
"error message must name the serialize failure, got: {wire}"
);
}
}