use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::domain::corpus::ClaimMark;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroundResponse {
pub query: String,
pub took_ms: u64,
pub stats: Stats,
pub docs: BTreeMap<String, DocFile>,
pub code: BTreeMap<String, serde_json::Value>,
pub warnings: Vec<Warning>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct Stats {
pub hits: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocFile {
pub summary: Option<String>,
pub keywords: Vec<String>,
pub score: f64,
#[serde(default)]
pub z_score: Option<f64>,
pub mtime: String,
pub corpus: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub stale: bool,
pub chunks: Vec<DocChunk>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocChunk {
pub chunk_id: String,
pub heading_path: Vec<String>,
pub line_range: [u32; 2],
pub score: f64,
#[serde(default)]
pub z_score: Option<f64>,
pub snippet: String,
#[serde(default)]
pub provenance: ChunkProvenance,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChunkProvenance {
#[serde(default)]
pub corpus: String,
#[serde(default)]
pub claim_marks: Vec<ClaimMark>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Warning {
pub code: String,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::corpus::ClaimStatus;
use serde_json::json;
fn fixture_response() -> GroundResponse {
let mut docs = BTreeMap::new();
docs.insert(
"/abs/path/to/file.md".into(),
DocFile {
summary: Some("First H1 plus first paragraph (or AI-generated later).".into()),
keywords: vec!["fts5".into(), "rrf".into(), "vector".into()],
score: 0.873,
z_score: None,
mtime: "2026-04-30T10:11:23Z".into(),
corpus: "tern-docs".into(),
path: Some("path/to/file.md".into()),
stale: false,
chunks: vec![DocChunk {
chunk_id: "abc123".into(),
heading_path: vec!["Indexing pipeline".into(), "Phase B".into()],
line_range: [134, 198],
score: 0.91,
z_score: None,
snippet: "first ~200 chars of chunk text\u{2026}".into(),
provenance: ChunkProvenance {
corpus: "tern-docs".into(),
claim_marks: vec![ClaimMark {
status: ClaimStatus::Confirmed,
line: 140,
reference: None,
note: None,
}],
},
}],
},
);
GroundResponse {
query: "tokio task spawning".into(),
took_ms: 47,
stats: Stats { hits: 22 },
docs,
code: BTreeMap::new(),
warnings: vec![],
}
}
#[test]
fn serialize_response_matches_documented_spec_shape() {
let actual = serde_json::to_value(fixture_response()).expect("serialize");
let expected = json!({
"query": "tokio task spawning",
"took_ms": 47,
"stats": { "hits": 22 },
"docs": {
"/abs/path/to/file.md": {
"summary": "First H1 plus first paragraph (or AI-generated later).",
"keywords": ["fts5", "rrf", "vector"],
"score": 0.873,
"z_score": null,
"mtime": "2026-04-30T10:11:23Z",
"corpus": "tern-docs",
"path": "path/to/file.md",
"stale": false,
"chunks": [{
"chunk_id": "abc123",
"heading_path": ["Indexing pipeline", "Phase B"],
"line_range": [134, 198],
"score": 0.91,
"z_score": null,
"snippet": "first ~200 chars of chunk text\u{2026}",
"provenance": {
"corpus": "tern-docs",
"claim_marks": [{
"status": "confirmed",
"line": 140,
"reference": null,
"note": null
}]
}
}]
}
},
"code": {},
"warnings": []
});
assert_eq!(actual, expected);
}
#[test]
fn serialize_null_summary_when_file_lacks_h1() {
let file = DocFile {
summary: None,
keywords: vec![],
score: 0.0,
z_score: None,
mtime: "2026-04-30T10:11:23Z".into(),
corpus: "docs".into(),
path: None,
stale: false,
chunks: vec![],
};
let actual = serde_json::to_value(&file).expect("serialize");
assert_eq!(actual["summary"], json!(null));
}
#[test]
fn serialize_warning_uses_code_and_message_fields() {
let warning = Warning {
code: "code-repos-empty".into(),
message: "no [[code_repo]] configured".into(),
};
let actual = serde_json::to_value(&warning).expect("serialize");
assert_eq!(
actual,
json!({ "code": "code-repos-empty", "message": "no [[code_repo]] configured" })
);
}
#[test]
fn serialize_empty_docs_and_code_render_as_objects_not_arrays() {
let response = GroundResponse {
query: String::new(),
took_ms: 0,
stats: Stats::default(),
docs: BTreeMap::new(),
code: BTreeMap::new(),
warnings: vec![],
};
let actual = serde_json::to_value(&response).expect("serialize");
assert!(actual["docs"].is_object());
assert!(actual["code"].is_object());
assert!(actual["warnings"].is_array());
}
#[test]
fn deserialize_chunk_without_provenance_defaults_corpus_to_empty() {
let legacy = json!({
"chunk_id": "abc123",
"heading_path": ["A", "B"],
"line_range": [1, 9],
"score": 0.5,
"snippet": "text"
});
let chunk: DocChunk = serde_json::from_value(legacy).expect("must deserialize");
assert_eq!(
chunk.provenance.corpus, "",
"absent provenance must default to an empty corpus, not error"
);
}
#[test]
fn path_field_absent_in_legacy_payload_deserializes_as_none() {
let legacy = json!({
"summary": null,
"keywords": [],
"score": 0.5,
"mtime": "2026-01-01T00:00:00Z",
"corpus": "docs",
"chunks": []
});
let doc: DocFile = serde_json::from_value(legacy).expect("must deserialize");
assert!(
doc.path.is_none(),
"absent path field must default to None, not error"
);
}
#[test]
fn path_none_serializes_as_null() {
let file = DocFile {
summary: None,
keywords: vec![],
score: 0.0,
z_score: None,
mtime: "2026-01-01T00:00:00Z".into(),
corpus: "docs".into(),
path: None,
stale: false,
chunks: vec![],
};
let actual = serde_json::to_value(&file).expect("serialize");
assert_eq!(actual["path"], json!(null));
}
#[test]
fn path_some_serializes_as_string() {
let file = DocFile {
summary: None,
keywords: vec![],
score: 0.0,
z_score: None,
mtime: "2026-01-01T00:00:00Z".into(),
corpus: "docs".into(),
path: Some("wiki/concepts.md".into()),
stale: false,
chunks: vec![],
};
let actual = serde_json::to_value(&file).expect("serialize");
assert_eq!(actual["path"], json!("wiki/concepts.md"));
}
}