1use std::collections::HashMap;
7use std::sync::{Arc, OnceLock};
8
9use memstead_schema::{
10 FieldType, Filterable, ManualAuthoring, PerEdgeDescription, RelationshipMode, Schema,
11 Serialization, TypeDefinition, all_types, type_by_name,
12};
13use serde::Serialize;
14
15use crate::chunking::estimate_tokens;
16use crate::graph::community::generate_auto_summary;
17use crate::ops::Direction;
18use crate::ops::{ExpansionInfo, Facets, ScoreBreakdown, SubsectionFacet, TermMatch};
19use crate::store::Store;
20use crate::{
21 ContextResult, Edge, Entity, InEdge, ListResult, LouvainOutput, SearchHit, SearchResult,
22};
23
24pub fn render_entity_markdown(entity: &Entity, sections_filter: Option<&[String]>) -> String {
30 let body_text = render_entity_body(entity, sections_filter);
31
32 let mut lines = Vec::new();
34 lines.push("---".to_string());
35 lines.push(format!("_hash: {}", entity.content_hash));
36 if let Some(kind) = &entity.stub_kind {
42 match kind {
43 crate::entity::StubKind::ForwardReference => {
44 lines.push("_stub_kind: forward_reference".to_string());
45 }
46 crate::entity::StubKind::LoadTime => {
47 lines.push("_stub_kind: load_time".to_string());
48 }
49 crate::entity::StubKind::Residual {
50 since_commit,
51 readonly_referrers,
52 } => {
53 lines.push("_stub_kind: residual".to_string());
54 if !since_commit.is_empty() {
55 lines.push(format!("_stub_since_commit: {since_commit}"));
56 }
57 if !readonly_referrers.is_empty() {
58 let refs: Vec<String> =
59 readonly_referrers.iter().map(|r| r.to_string()).collect();
60 lines.push(format!("_stub_readonly_referrers: [{}]", refs.join(", ")));
61 }
62 }
63 }
64 }
65 let tokens = estimate_tokens(&body_text);
66 lines.push(format!("_tokens: {tokens}"));
67
68 let is_filtered = sections_filter.is_some_and(|f| {
71 let all_keys: Vec<&String> = entity.sections.keys().collect();
72 f.len() < all_keys.len() || !all_keys.iter().all(|k| f.iter().any(|fk| fk == *k))
73 });
74 if is_filtered {
75 let full_body = render_entity_body(entity, None);
76 let full_tokens = estimate_tokens(&full_body);
77 lines.push(format!("_tokens_unfiltered_body: {full_tokens}"));
78 }
79
80 for (key, value) in &entity.metadata {
82 lines.push(format!("{key}: {value}"));
83 }
84 lines.push("---".to_string());
85 lines.push(String::new());
86
87 lines.push(body_text);
88 lines.join("\n")
89}
90
91pub fn rendered_body_tokens(entity: &Entity, sections_filter: Option<&[String]>) -> usize {
98 estimate_tokens(&render_entity_body(entity, sections_filter))
99}
100
101fn render_entity_body(entity: &Entity, sections_filter: Option<&[String]>) -> String {
108 let mut body = Vec::new();
109
110 body.push(format!("# {}", entity.title));
111 body.push(String::new());
112
113 let type_def = lookup_builtin_type(&entity.entity_type);
121
122 for (key, content) in &entity.sections {
123 if let Some(filter) = sections_filter
124 && !filter.iter().any(|f| f == key)
125 {
126 continue;
127 }
128 let heading = section_heading_for(type_def.as_deref(), key);
129 body.push(format!("## {heading}"));
130 body.push(String::new());
131 body.push(content.trim().to_string());
132 body.push(String::new());
133 }
134
135 if !entity.relationships.is_empty()
136 && sections_filter.is_none_or(|f| f.iter().any(|s| s == "relationships"))
137 {
138 body.push("## Relationships".to_string());
139 body.push(String::new());
140 for rel in &entity.relationships {
141 match rel
145 .description
146 .as_deref()
147 .map(str::trim)
148 .filter(|s| !s.is_empty())
149 {
150 Some(text) => body.push(format!(
151 "- **{}**: [[{}]] \u{2014} {text}",
152 rel.rel_type, rel.target
153 )),
154 None => body.push(format!("- **{}**: [[{}]]", rel.rel_type, rel.target)),
155 }
156 }
157 body.push(String::new());
158 }
159
160 body.join("\n")
161}
162
163pub fn render_relations_markdown(
168 entity_id: &str,
169 outgoing: &[Edge],
170 incoming: &[InEdge],
171) -> String {
172 let mut lines = Vec::new();
173 lines.push(String::new());
174 lines.push("## Relations".to_string());
175 lines.push(String::new());
176
177 if outgoing.is_empty() && incoming.is_empty() {
178 lines.push(format!("(no relations for {entity_id})"));
179 lines.push(String::new());
180 return lines.join("\n");
181 }
182
183 if !outgoing.is_empty() {
184 lines.push("### Outgoing".to_string());
185 for e in outgoing {
186 lines.push(format!("- **{}** → [[{}]]", e.rel_type, e.target));
187 }
188 lines.push(String::new());
189 }
190
191 if !incoming.is_empty() {
192 lines.push("### Incoming".to_string());
193 for e in incoming {
194 lines.push(format!("- [[{}]] → **{}** → (this)", e.from, e.rel_type));
195 }
196 lines.push(String::new());
197 }
198
199 lines.join("\n")
200}
201
202pub fn render_relations_json(
205 entity_id: &str,
206 outgoing: &[Edge],
207 incoming: &[InEdge],
208) -> serde_json::Value {
209 let out: Vec<serde_json::Value> = outgoing
210 .iter()
211 .map(|e| {
212 serde_json::json!({
213 "type": e.rel_type,
214 "target": e.target.to_string(),
215 "source": format!("{:?}", e.source).to_lowercase(),
216 })
217 })
218 .collect();
219
220 let inc: Vec<serde_json::Value> = incoming
221 .iter()
222 .map(|e| {
223 serde_json::json!({
224 "type": e.rel_type,
225 "from": e.from.to_string(),
226 "source": format!("{:?}", e.source).to_lowercase(),
227 })
228 })
229 .collect();
230
231 serde_json::json!({
232 "entity": entity_id,
233 "outgoing": out,
234 "incoming": inc,
235 })
236}
237
238pub fn render_search_markdown(result: &SearchResult, offset: usize) -> String {
244 let mut lines = Vec::new();
245
246 lines.push("---".to_string());
247 lines.push(format!("_total: {}", result.total));
248 lines.push(format!("_returned: {}", result.returned));
249 lines.push(format!("_offset: {offset}"));
250 lines.push(format!("_total_tokens: {}", result.total_tokens));
251 lines.push("---".to_string());
252 lines.push(String::new());
253
254 if !result.warnings.is_empty() {
255 lines.push("## Filter warnings".to_string());
260 for w in &result.warnings {
261 lines.push(format!("- **{}**: {}", w.code(), w.message()));
262 }
263 lines.push(String::new());
264 }
265
266 if let Some(facets) = &result.facets
267 && let Some(block) = render_facets_block(facets)
268 {
269 lines.push(block);
270 }
271
272 for hit in &result.hits {
273 lines.push(format!(
274 "### {} — {} (_score: {:.1}, _tokens: {})",
275 hit.id, hit.title, hit.score, hit.tokens,
276 ));
277 lines.push(hit_summary_line(hit));
278 if let Some(line) = render_matched_terms_line(hit.matched_terms.as_ref()) {
279 lines.push(line);
280 }
281 if let Some(line) = render_score_breakdown_line(hit.score_breakdown.as_ref()) {
282 lines.push(line);
283 }
284 if let Some(line) = render_heading_paths_line(hit.matched_terms.as_ref()) {
285 lines.push(line);
286 }
287 if let Some(line) = render_expansion_line(hit.expansion.as_ref()) {
288 lines.push(line);
289 }
290 if let Some(snippet) = &hit.snippet {
291 lines.push(format!("> ...{snippet}..."));
292 }
293 lines.push(String::new());
294 }
295
296 lines.join("\n")
297}
298
299fn render_facets_block(facets: &Facets) -> Option<String> {
307 let blocks: Vec<(&str, String)> = [
308 ("by_type", &facets.by_type),
309 ("by_mem", &facets.by_mem),
310 ("by_level", &facets.by_level),
311 ("by_status", &facets.by_status),
312 ("by_confidence", &facets.by_confidence),
313 ("by_expansion", &facets.by_expansion),
314 ]
315 .into_iter()
316 .filter_map(|(name, bucket)| format_facet_bucket(bucket).map(|s| (name, s)))
317 .collect();
318
319 if blocks.is_empty() && facets.by_subsection.is_empty() {
320 return None;
321 }
322
323 let mut out = String::new();
324 out.push_str("## Facets\n");
325 for (name, body) in blocks {
326 out.push_str(&format!("- **{name}:** {body}\n"));
327 }
328 if !facets.by_subsection.is_empty() {
329 out.push_str("- **by_subsection:**\n");
330 for entry in &facets.by_subsection {
331 out.push_str(&format!(" - {}\n", format_subsection_facet(entry)));
332 }
333 }
334 Some(out)
335}
336
337fn format_facet_bucket(bucket: &HashMap<String, usize>) -> Option<String> {
338 if bucket.is_empty() {
339 return None;
340 }
341 let mut entries: Vec<(&String, &usize)> = bucket.iter().collect();
342 entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
343 Some(
344 entries
345 .iter()
346 .map(|(k, v)| format!("{k}={v}"))
347 .collect::<Vec<_>>()
348 .join(", "),
349 )
350}
351
352fn format_subsection_facet(entry: &SubsectionFacet) -> String {
353 let path = entry.path.join(" › ");
354 format!("`{path}`: {}", entry.count)
355}
356
357fn render_matched_terms_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
362 let matched = matched?;
363 if matched.is_empty() {
364 return None;
365 }
366 let mut terms: Vec<(&String, &Vec<TermMatch>)> = matched.iter().collect();
367 terms.sort_by(|a, b| a.0.cmp(b.0));
368 let groups: Vec<String> = terms
369 .iter()
370 .map(|(term, tms)| {
371 let mut field_counts: HashMap<&str, usize> = HashMap::new();
372 for tm in tms.iter() {
373 *field_counts.entry(tm.field.as_str()).or_insert(0) += 1;
374 }
375 let mut fields: Vec<(&&str, &usize)> = field_counts.iter().collect();
376 fields.sort_by(|a, b| a.0.cmp(b.0));
377 let inner: Vec<String> = fields.iter().map(|(f, n)| format!("{f}×{n}")).collect();
378 format!("`{term}` ({})", inner.join(", "))
379 })
380 .collect();
381 Some(format!("**Matched terms:** {}", groups.join(", ")))
382}
383
384fn render_score_breakdown_line(breakdown: Option<&ScoreBreakdown>) -> Option<String> {
389 let b = breakdown?;
390 let mut parts: Vec<String> = Vec::new();
391 parts.push(format!("bm25 {:.1}", b.bm25));
392 parts.push(format!("title {:.1}", b.title_boost));
393 let mut fields: Vec<(&String, &f32)> = b.field_weights.iter().collect();
394 fields.sort_by(|a, b| a.0.cmp(b.0));
395 for (k, v) in fields {
396 parts.push(format!("{k} {v:.1}"));
397 }
398 if let Some(decay) = b.expansion_decay {
399 parts.push(format!("expansion_decay ×{decay:.1}"));
400 }
401 Some(format!("**Score:** {}", parts.join(" + ")))
402}
403
404fn render_heading_paths_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
408 let matched = matched?;
409 let mut paths: Vec<Vec<String>> = Vec::new();
410 let mut term_keys: Vec<&String> = matched.keys().collect();
411 term_keys.sort();
412 for term in term_keys {
413 for tm in &matched[term] {
414 if let Some(path) = &tm.heading_path
415 && !path.is_empty()
416 && !paths.iter().any(|p| p == path)
417 {
418 paths.push(path.clone());
419 }
420 }
421 }
422 if paths.is_empty() {
423 return None;
424 }
425 let formatted: Vec<String> = paths.iter().map(|p| p.join(" › ")).collect();
426 Some(format!("**Heading path:** {}", formatted.join("; ")))
427}
428
429fn render_expansion_line(expansion: Option<&ExpansionInfo>) -> Option<String> {
432 let e = expansion?;
433 Some(format!(
434 "**Expansion:** from `{}` via `{}` (depth {})",
435 e.of, e.via_edge, e.depth,
436 ))
437}
438
439pub fn render_list_markdown(result: &ListResult) -> String {
441 let mut lines = Vec::new();
442
443 lines.push("---".to_string());
444 lines.push(format!("_total: {}", result.total));
445 lines.push(format!("_returned: {}", result.returned));
446 lines.push(format!("_offset: {}", result.offset));
447 lines.push(format!("_total_tokens: {}", result.total_tokens));
448 lines.push("---".to_string());
449 lines.push(String::new());
450
451 if !result.warnings.is_empty() {
452 lines.push("## Filter warnings".to_string());
453 for w in &result.warnings {
454 lines.push(format!("- **{}**: {}", w.code(), w.message()));
455 }
456 lines.push(String::new());
457 }
458
459 for hit in &result.hits {
460 let meta = hit
461 .sections
462 .get("level")
463 .map(|l| format!("{l}, "))
464 .unwrap_or_default();
465 lines.push(format!(
466 "### {} — {} ({meta}_tokens: {})",
467 hit.id, hit.title, hit.tokens,
468 ));
469 lines.push(hit_summary_line(hit));
470 lines.push(String::new());
471 }
472
473 lines.join("\n")
474}
475
476pub fn render_community_context_section(result: &ContextResult, cluster_id: &str) -> String {
484 let mut lines = Vec::new();
485 lines.push(String::new());
486 lines.push("## Community Context".to_string());
487 lines.push(String::new());
488 lines.push(format!("**Cluster {cluster_id}**"));
489 lines.push(String::new());
490
491 if !result.neighbors.is_empty() {
492 lines.push("### Neighbors".to_string());
493 for n in &result.neighbors {
494 let dir = match n.direction {
495 Direction::Outgoing => "→",
496 Direction::Incoming => "←",
497 };
498 lines.push(format!(
499 "- {} —{}— **{}** ({})",
500 result.entity_id, dir, n.id, n.relationship,
501 ));
502 }
503 lines.push(String::new());
504 }
505
506 lines.join("\n")
507}
508
509pub fn render_context_markdown(result: &ContextResult, cluster_id: &str) -> String {
511 let mut lines = Vec::new();
512
513 lines.push("---".to_string());
514 lines.push(format!("_cluster_id: {cluster_id}"));
515 lines.push("---".to_string());
516 lines.push(String::new());
517 lines.push(format!("## Cluster {cluster_id}"));
518 lines.push(String::new());
519
520 lines.push("### Neighbors".to_string());
522 for n in &result.neighbors {
523 let dir = match n.direction {
524 Direction::Outgoing => "→",
525 Direction::Incoming => "←",
526 };
527 lines.push(format!(
528 "- {} —{}— **{}** ({})",
529 result.entity_id, dir, n.id, n.relationship,
530 ));
531 }
532 lines.push(String::new());
533
534 lines.join("\n")
535}
536
537pub fn render_overview_markdown(output: &LouvainOutput, store: &Store) -> String {
540 let mut lines = Vec::new();
541
542 let entity_count: usize = output.clusters.values().map(|c| c.entities.len()).sum();
543
544 lines.push("---".to_string());
545 lines.push(format!("_cluster_count: {}", output.count));
546 lines.push(format!("_entity_count: {entity_count}"));
547 let mod_str = if output.modularity == 0.0 {
549 "0".to_string()
550 } else {
551 format!("{:.4}", output.modularity)
552 };
553 lines.push(format!("_modularity: {mod_str}"));
554 lines.push("---".to_string());
555 lines.push(String::new());
556
557 let mut cluster_ids: Vec<&String> = output.clusters.keys().collect();
559 cluster_ids.sort();
560
561 for cluster_id in cluster_ids {
562 let info = &output.clusters[cluster_id];
563 let summary = generate_auto_summary(store, &info.entities);
564
565 lines.push(format!(
566 "## Cluster {cluster_id} ({} entities)",
567 info.entities.len(),
568 ));
569 if !summary.is_empty() {
570 lines.push(summary);
571 }
572 for entity_id in &info.entities {
573 lines.push(format!("- {entity_id}"));
574 }
575 lines.push(String::new());
576 }
577
578 lines.join("\n")
579}
580
581#[derive(Serialize)]
597pub struct SearchHitEnvelope<'a> {
598 #[serde(flatten)]
599 pub hit: &'a SearchHit,
600 pub summary_heading: String,
601 pub summary_value: String,
602}
603
604#[derive(Serialize)]
614pub struct SearchResultEnvelope<'a> {
615 #[serde(rename = "_total")]
616 pub total: usize,
617 #[serde(rename = "_returned")]
618 pub returned: usize,
619 #[serde(rename = "_offset")]
620 pub offset: usize,
621 #[serde(rename = "_total_tokens")]
625 pub total_tokens: usize,
626 pub hits: Vec<SearchHitEnvelope<'a>>,
627 #[serde(skip_serializing_if = "Option::is_none")]
632 pub facets: Option<&'a Facets>,
633 #[serde(skip_serializing_if = "Vec::is_empty")]
634 pub warnings: &'a Vec<crate::ops::WarningHint>,
635}
636
637#[derive(Serialize)]
643pub struct ListResultEnvelope<'a> {
644 #[serde(rename = "_total")]
645 pub total: usize,
646 #[serde(rename = "_returned")]
647 pub returned: usize,
648 #[serde(rename = "_offset")]
649 pub offset: usize,
650 #[serde(rename = "_total_tokens")]
651 pub total_tokens: usize,
652 pub hits: Vec<SearchHitEnvelope<'a>>,
653 #[serde(skip_serializing_if = "Vec::is_empty")]
654 pub warnings: &'a Vec<crate::ops::WarningHint>,
655}
656
657pub fn build_entity_envelope(
691 entity: &Entity,
692 rendered_body_tokens: usize,
693 full_tokens: Option<usize>,
694 sections_filter: Option<&[String]>,
695 schema_anchor: Option<&str>,
696 outgoing_edges: &[crate::store::Edge],
697) -> serde_json::Value {
698 let mut envelope = serde_json::Map::new();
699 envelope.insert(
700 "_hash".to_string(),
701 serde_json::Value::String(entity.content_hash.clone()),
702 );
703 envelope.insert(
704 "id".to_string(),
705 serde_json::Value::String(entity.id.to_string()),
706 );
707 envelope.insert(
708 "mem".to_string(),
709 serde_json::Value::String(entity.mem.clone()),
710 );
711 envelope.insert(
712 "type".to_string(),
713 serde_json::Value::String(entity.entity_type.clone()),
714 );
715
716 let mut metadata = serde_json::Map::new();
732 for (key, value) in &entity.metadata {
733 if key.starts_with('_')
734 || crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str())
735 {
736 continue;
737 }
738 metadata.insert(
739 key.clone(),
740 serde_json::Value::String(value.to_frontmatter_string()),
741 );
742 }
743 envelope.insert("metadata".to_string(), serde_json::Value::Object(metadata));
744
745 envelope.insert(
746 "_tokens".to_string(),
747 serde_json::Value::Number(serde_json::Number::from(rendered_body_tokens)),
748 );
749 if let Some(t) = full_tokens {
750 envelope.insert(
757 "_tokens_unfiltered_body".to_string(),
758 serde_json::Value::Number(serde_json::Number::from(t)),
759 );
760 }
761 if let Some(s) = schema_anchor {
762 envelope.insert(
763 "_mem_schema".to_string(),
764 serde_json::Value::String(s.to_string()),
765 );
766 }
767
768 if let Some(kind) = &entity.stub_kind {
769 envelope.insert(
770 "_stub_kind".to_string(),
771 serde_json::to_value(kind).unwrap_or(serde_json::Value::Null),
772 );
773 }
774
775 let mut sections = serde_json::Map::new();
776 for (key, content) in &entity.sections {
777 if let Some(filter) = sections_filter
778 && !filter.iter().any(|f| f == key)
779 {
780 continue;
781 }
782 sections.insert(key.clone(), serde_json::Value::String(content.clone()));
783 }
784 envelope.insert("sections".to_string(), serde_json::Value::Object(sections));
785
786 let resolve_source = |rel: &crate::entity::Relationship| -> &'static str {
797 outgoing_edges
798 .iter()
799 .find(|e| e.rel_type == rel.rel_type && e.target == rel.target)
800 .map(|e| match e.source {
801 crate::store::EdgeSource::BodyLink => "body_link",
802 crate::store::EdgeSource::Hierarchy => "hierarchy",
803 crate::store::EdgeSource::Explicit => "explicit",
804 })
805 .unwrap_or("explicit")
806 };
807 let relationships = entity
808 .relationships
809 .iter()
810 .map(|rel| {
811 let mut obj = serde_json::Map::new();
812 obj.insert(
813 "rel_type".to_string(),
814 serde_json::Value::String(rel.rel_type.clone()),
815 );
816 obj.insert(
817 "target".to_string(),
818 serde_json::Value::String(rel.target.to_string()),
819 );
820 obj.insert(
821 "source".to_string(),
822 serde_json::Value::String(resolve_source(rel).to_string()),
823 );
824 if let Some(desc) = rel
825 .description
826 .as_deref()
827 .map(str::trim)
828 .filter(|s| !s.is_empty())
829 {
830 obj.insert(
831 "description".to_string(),
832 serde_json::Value::String(desc.to_string()),
833 );
834 }
835 serde_json::Value::Object(obj)
836 })
837 .collect();
838 envelope.insert(
839 "relationships".to_string(),
840 serde_json::Value::Array(relationships),
841 );
842
843 serde_json::Value::Object(envelope)
844}
845
846pub fn build_search_envelope<'a>(
848 result: &'a SearchResult,
849 offset: usize,
850) -> SearchResultEnvelope<'a> {
851 SearchResultEnvelope {
852 total: result.total,
853 returned: result.returned,
854 offset,
855 total_tokens: result.total_tokens,
856 hits: result.hits.iter().map(build_hit_envelope).collect(),
857 facets: result.facets.as_ref(),
858 warnings: &result.warnings,
859 }
860}
861
862pub fn build_list_envelope(result: &ListResult) -> ListResultEnvelope<'_> {
864 ListResultEnvelope {
865 total: result.total,
866 returned: result.returned,
867 offset: result.offset,
868 total_tokens: result.total_tokens,
869 hits: result.hits.iter().map(build_hit_envelope).collect(),
870 warnings: &result.warnings,
871 }
872}
873
874fn build_hit_envelope(hit: &SearchHit) -> SearchHitEnvelope<'_> {
875 let (heading, value) = hit_summary_pair(hit);
876 SearchHitEnvelope {
877 hit,
878 summary_heading: heading,
879 summary_value: value,
880 }
881}
882
883fn hit_summary_line(hit: &SearchHit) -> String {
893 let (heading, value) = hit_summary_pair(hit);
894 format!("**{heading}**: {value}")
895}
896
897fn hit_summary_pair(hit: &SearchHit) -> (String, String) {
907 if let Some(summary) = &hit.summary {
908 return (summary.heading.clone(), summary.value.clone());
909 }
910 summary_pair(type_by_name(&hit.entity_type).as_deref(), &hit.sections)
911}
912
913fn summary_pair(
915 schema: Option<&TypeDefinition>,
916 sections: &HashMap<String, String>,
917) -> (String, String) {
918 match schema {
919 Some(schema) => lead_section_pair(schema, |k| sections.get(k).map(String::as_str)),
920 None => ("Summary".to_string(), "—".to_string()),
921 }
922}
923
924pub(crate) fn lead_section_pair<'a>(
932 schema: &TypeDefinition,
933 get_section: impl Fn(&str) -> Option<&'a str>,
934) -> (String, String) {
935 let Some(section) = schema
936 .required_sections()
937 .next()
938 .or(schema.sections.first())
939 else {
940 return ("Summary".to_string(), "—".to_string());
941 };
942 let value = get_section(section.key.as_str()).unwrap_or("—");
943 (section.heading.clone(), value.to_string())
944}
945
946fn section_key_to_heading(key: &str) -> String {
950 let mut chars = key.chars();
951 match chars.next() {
952 None => String::new(),
953 Some(c) => {
954 let first: String = c.to_uppercase().collect();
955 let rest: String = chars.map(|c| if c == '_' { ' ' } else { c }).collect();
956 format!("{first}{rest}")
957 }
958 }
959}
960
961fn section_heading_for(type_def: Option<&TypeDefinition>, key: &str) -> String {
968 type_def
969 .and_then(|t| t.sections.iter().find(|s| s.key == key))
970 .map(|s| s.heading.clone())
971 .unwrap_or_else(|| section_key_to_heading(key))
972}
973
974fn lookup_builtin_type(name: &str) -> Option<Arc<TypeDefinition>> {
983 static CACHE: OnceLock<Vec<Arc<Schema>>> = OnceLock::new();
984 let schemas =
985 CACHE.get_or_init(|| memstead_schema::builtins::load_builtin_schemas().unwrap_or_default());
986 for s in schemas {
987 if let Some(t) = s.get_type(name) {
988 return Some(t);
989 }
990 }
991 None
992}
993
994pub fn render_type_catalog_markdown() -> String {
1000 render_type_catalog_lines(all_types())
1001}
1002
1003pub fn render_type_catalog_markdown_for(schema: &Schema) -> String {
1009 let mut types: Vec<Arc<TypeDefinition>> = schema.types.values().cloned().collect();
1010 types.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
1011 render_type_catalog_lines(types)
1012}
1013
1014fn render_type_catalog_lines(types: Vec<Arc<TypeDefinition>>) -> String {
1015 let mut lines = vec![
1016 "# Available types".to_string(),
1017 String::new(),
1018 "Run `memstead type <name>` (or call `memstead_schema` with a type name) to see its metadata fields, sections, relationship types, and writing guidance."
1019 .to_string(),
1020 String::new(),
1021 ];
1022 for schema in types {
1023 let required_sections = schema.required_sections().count();
1024 let total_sections = schema.sections.len();
1025 let metadata_count = schema.metadata_fields.len();
1026 lines.push(format!(
1027 "- **{}** — {} sections ({} required), {} metadata fields, staleness {}d",
1028 schema.name.as_str(),
1029 total_sections,
1030 required_sections,
1031 metadata_count,
1032 schema.staleness_threshold_days,
1033 ));
1034 }
1035 lines.push(String::new());
1036 lines.join("\n")
1037}
1038
1039pub fn render_type_info_markdown(schema: &TypeDefinition) -> String {
1041 let mut lines = Vec::new();
1042 lines.push(format!("# Type: {}", schema.name.as_str()));
1043 lines.push(String::new());
1044 lines.push(format!(
1045 "Staleness threshold: {} days. Hierarchy: `{}`.",
1046 schema.staleness_threshold_days, schema.hierarchy_relationship,
1047 ));
1048 lines.push(String::new());
1049
1050 lines.push("## Metadata fields".to_string());
1052 for field in &schema.metadata_fields {
1053 lines.push(format!("- {}", describe_metadata_field(field)));
1054 }
1055 lines.push(String::new());
1056
1057 lines.push("## Sections".to_string());
1059 for section in &schema.sections {
1060 let req = if section.required {
1061 "required"
1062 } else {
1063 "optional"
1064 };
1065 let catch_all = if section.catch_all { ", catch-all" } else { "" };
1066 lines.push(format!(
1067 "- **{}** ({req}{catch_all}, search_weight: {:.1})",
1068 section.key, section.search_weight,
1069 ));
1070 for rule in §ion.write_rules {
1071 lines.push(format!(" - Write rule: {rule}"));
1072 }
1073 }
1074 lines.push(String::new());
1075
1076 lines.push("## Relationship types (with edge weights)".to_string());
1078 for (rel_type, weight) in &schema.edge_weights {
1079 if rel_type == "_default" {
1080 continue;
1081 }
1082 let mut flags: Vec<&str> = Vec::new();
1083 if rel_type == &schema.hierarchy_relationship {
1084 flags.push("hierarchy");
1085 }
1086 if schema
1087 .propagating_relationships
1088 .iter()
1089 .any(|r| r == rel_type)
1090 {
1091 flags.push("propagating");
1092 }
1093 let flag_str = if flags.is_empty() {
1094 String::new()
1095 } else {
1096 format!(" ({})", flags.join(", "))
1097 };
1098 lines.push(format!("- **{rel_type}**: {weight}{flag_str}"));
1099 }
1100 if let Some((_, default_weight)) = schema.edge_weights.iter().find(|(n, _)| *n == "_default") {
1102 lines.push(format!(
1103 "- _default_ (any other relationship type): {default_weight}"
1104 ));
1105 }
1106 lines.push(String::new());
1107
1108 if !schema.write_rules.is_empty() {
1110 lines.push("## Writing guidance".to_string());
1111 for rule in &schema.write_rules {
1112 lines.push(format!("- {rule}"));
1113 }
1114 lines.push(String::new());
1115 }
1116
1117 let system_msg = schema.system_message_str();
1119 if !system_msg.is_empty() {
1120 lines.push("## System context".to_string());
1121 lines.push(system_msg.to_string());
1122 lines.push(String::new());
1123 }
1124
1125 lines.join("\n")
1126}
1127
1128pub fn per_edge_description_str(p: PerEdgeDescription) -> &'static str {
1134 match p {
1135 PerEdgeDescription::Forbidden => "forbidden",
1136 PerEdgeDescription::Optional => "optional",
1137 PerEdgeDescription::Required => "required",
1138 }
1139}
1140
1141pub fn manual_authoring_str(p: ManualAuthoring) -> &'static str {
1143 match p {
1144 ManualAuthoring::Allow => "allow",
1145 ManualAuthoring::Warn => "warn",
1146 ManualAuthoring::Forbidden => "forbidden",
1147 }
1148}
1149
1150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1166pub enum SchemaVerbosity {
1167 #[default]
1168 Full,
1169 Lite,
1170}
1171
1172impl SchemaVerbosity {
1173 pub fn from_wire(s: &str) -> Option<Self> {
1178 match s {
1179 "full" => Some(Self::Full),
1180 "lite" => Some(Self::Lite),
1181 _ => None,
1182 }
1183 }
1184
1185 pub fn as_wire(self) -> &'static str {
1187 match self {
1188 Self::Full => "full",
1189 Self::Lite => "lite",
1190 }
1191 }
1192}
1193
1194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1221pub enum OriginClass {
1222 FirstParty,
1224 #[default]
1227 ThirdParty,
1228}
1229
1230impl OriginClass {
1231 pub fn as_wire(self) -> &'static str {
1235 match self {
1236 Self::FirstParty => "first-party",
1237 Self::ThirdParty => "third-party",
1238 }
1239 }
1240
1241 pub fn is_third_party(self) -> bool {
1244 matches!(self, Self::ThirdParty)
1245 }
1246}
1247
1248pub fn build_schema_payload(
1264 schema: &Arc<Schema>,
1265 used_by: Vec<String>,
1266 verbosity: SchemaVerbosity,
1267 origin: OriginClass,
1268) -> serde_json::Value {
1269 let manifest = &schema.manifest;
1270 let verbosity = if origin.is_third_party() {
1278 SchemaVerbosity::Lite
1279 } else {
1280 verbosity
1281 };
1282
1283 let relationships: Vec<serde_json::Value> = manifest
1294 .relationships
1295 .definitions
1296 .iter()
1297 .filter(|d| d.name != "_default")
1298 .map(|d| {
1299 serde_json::json!({
1320 "name": d.name,
1321 "description": d.description,
1322 "when_to_use": d.when_to_use,
1323 "default_weight": d.default_weight,
1324 "acyclic": d.acyclic,
1325 "per_edge_description": per_edge_description_str(d.per_edge_description),
1326 "manual_authoring": manual_authoring_str(d.manual_authoring),
1327 "allowed_sources": d.source_types,
1328 "allowed_targets": d.target_types,
1329 })
1330 })
1331 .collect();
1332
1333 let cross_mem_relationships: Vec<serde_json::Value> = manifest
1340 .cross_mem_relationships
1341 .iter()
1342 .map(|entry| {
1343 let definitions: Vec<serde_json::Value> = entry
1344 .definitions
1345 .iter()
1346 .filter(|d| d.name != "_default")
1347 .map(|d| {
1348 serde_json::json!({
1349 "name": d.name,
1350 "description": d.description,
1351 "when_to_use": d.when_to_use,
1352 "default_weight": d.default_weight,
1353 "source_types": d.source_types,
1354 "target_types": d.target_types,
1355 "per_edge_description": per_edge_description_str(d.per_edge_description),
1356 })
1357 })
1358 .collect();
1359 serde_json::json!({
1360 "to_schema": entry.to_schema,
1361 "definitions": definitions,
1362 })
1363 })
1364 .collect();
1365
1366 let types_full: Vec<serde_json::Value> = manifest
1369 .types
1370 .iter()
1371 .filter_map(|name| schema.types.get(name.as_str()).map(|td| (name, td)))
1372 .map(|(_, td)| {
1373 let sections: Vec<serde_json::Value> = td
1374 .sections
1375 .iter()
1376 .map(|s| {
1377 serde_json::json!({
1378 "key": s.key,
1379 "heading": s.heading,
1380 "required": s.required,
1381 "write_rules": s.write_rules,
1382 })
1383 })
1384 .collect();
1385
1386 let fields: Vec<serde_json::Value> = td
1387 .metadata_fields
1388 .iter()
1389 .map(|f| {
1390 let mut obj = serde_json::json!({
1391 "name": f.key,
1392 "description": f.description,
1393 "required": !f.optional,
1394 });
1395 if let Some(enum_values) = &f.enum_values {
1396 obj.as_object_mut()
1397 .unwrap()
1398 .insert("enum".into(), serde_json::json!(enum_values));
1399 }
1400 if let Some(default) = &f.default_value {
1407 obj.as_object_mut()
1408 .unwrap()
1409 .insert("default".into(), serde_json::json!(default));
1410 }
1411 obj.as_object_mut().unwrap().insert(
1417 "filterable".into(),
1418 match f.filterable.as_wire_str() {
1419 Some(s) => serde_json::json!(s),
1420 None => serde_json::Value::Null,
1421 },
1422 );
1423 obj
1424 })
1425 .collect();
1426
1427 serde_json::json!({
1432 "name": td.name,
1433 "description": td.description,
1434 "when_to_use": td.when_to_use,
1435 "sections": sections,
1436 "fields": fields,
1437 "writing_guidance": td.write_rules,
1438 "system_context": td.system_message_str(),
1439 "staleness_threshold_days": td.staleness_threshold_days,
1440 "propagating_relationships": td.propagating_relationships,
1441 })
1442 })
1443 .collect();
1444
1445 let mode = match manifest.relationships.mode {
1446 RelationshipMode::Strict => "strict",
1447 RelationshipMode::Open => "open",
1448 };
1449
1450 let full = verbosity == SchemaVerbosity::Full;
1451
1452 let mut payload = serde_json::json!({
1456 "ref": format!("{}@{}", manifest.name, schema.version),
1457 "relationship_mode": mode,
1458 "community": {
1459 "resolution": manifest.community.resolution,
1460 "seed": manifest.community.seed,
1461 },
1462 "used_by": used_by,
1463 "origin": origin.as_wire(),
1469 });
1470 let obj = payload.as_object_mut().unwrap();
1471
1472 if full {
1477 obj.insert(
1478 "description".into(),
1479 serde_json::Value::String(manifest.description.clone()),
1480 );
1481 obj.insert(
1482 "when_to_use".into(),
1483 serde_json::Value::String(manifest.when_to_use.clone()),
1484 );
1485 }
1486
1487 if let Some(target) = &manifest.alias_target_rel_type {
1496 obj.insert(
1497 "alias_target_rel_type".into(),
1498 serde_json::Value::String(target.clone()),
1499 );
1500 }
1501
1502 if full && let Some(dwg) = &manifest.default_writing_guidance {
1509 let mut block = serde_json::Map::new();
1510 if let Some(avoid) = &dwg.avoid {
1511 block.insert("avoid".into(), serde_json::Value::String(avoid.clone()));
1512 }
1513 if let Some(goal) = &dwg.goal {
1514 block.insert("goal".into(), serde_json::Value::String(goal.clone()));
1515 }
1516 if !block.is_empty() {
1517 obj.insert(
1518 "default_writing_guidance".into(),
1519 serde_json::Value::Object(block),
1520 );
1521 }
1522 }
1523
1524 if full {
1525 obj.insert(
1526 "relationships".into(),
1527 serde_json::Value::Array(relationships),
1528 );
1529 if !cross_mem_relationships.is_empty() {
1533 obj.insert(
1534 "cross_mem_relationships".into(),
1535 serde_json::Value::Array(cross_mem_relationships),
1536 );
1537 }
1538 obj.insert("types".into(), serde_json::Value::Array(types_full));
1539 } else {
1540 let relationships_summary: Vec<serde_json::Value> = relationships
1550 .iter()
1551 .map(|r| {
1552 serde_json::json!({
1553 "name": r["name"],
1554 "allowed_sources": r["allowed_sources"],
1555 "allowed_targets": r["allowed_targets"],
1556 "manual_authoring": r["manual_authoring"],
1557 "acyclic": r["acyclic"],
1558 "per_edge_description": r["per_edge_description"],
1559 })
1560 })
1561 .collect();
1562 obj.insert(
1563 "relationships_summary".into(),
1564 serde_json::Value::Array(relationships_summary),
1565 );
1566
1567 if !cross_mem_relationships.is_empty() {
1571 let cross_summary: Vec<serde_json::Value> = cross_mem_relationships
1572 .iter()
1573 .map(|e| {
1574 let definitions: Vec<serde_json::Value> = e["definitions"]
1575 .as_array()
1576 .map(|defs| {
1577 defs.iter()
1578 .map(|d| {
1579 serde_json::json!({
1580 "name": d["name"],
1581 "source_types": d["source_types"],
1582 "target_types": d["target_types"],
1583 })
1584 })
1585 .collect()
1586 })
1587 .unwrap_or_default();
1588 serde_json::json!({
1589 "to_schema": e["to_schema"],
1590 "definitions": definitions,
1591 })
1592 })
1593 .collect();
1594 obj.insert(
1595 "cross_mem_relationships_summary".into(),
1596 serde_json::Value::Array(cross_summary),
1597 );
1598 }
1599
1600 let types_summary: Vec<serde_json::Value> = types_full
1610 .iter()
1611 .map(|t| {
1612 let sections: Vec<serde_json::Value> = t["sections"]
1613 .as_array()
1614 .map(|secs| {
1615 secs.iter()
1616 .map(|s| {
1617 serde_json::json!({
1618 "key": s["key"],
1619 "required": s["required"],
1620 })
1621 })
1622 .collect()
1623 })
1624 .unwrap_or_default();
1625 let fields: Vec<serde_json::Value> = t["fields"]
1626 .as_array()
1627 .map(|fs| {
1628 fs.iter()
1629 .map(|f| {
1630 let mut o = serde_json::Map::new();
1631 o.insert("name".into(), f["name"].clone());
1632 o.insert("required".into(), f["required"].clone());
1633 if let Some(e) = f.get("enum") {
1634 o.insert("enum".into(), e.clone());
1635 }
1636 if let Some(d) = f.get("default") {
1637 o.insert("default".into(), d.clone());
1638 }
1639 serde_json::Value::Object(o)
1640 })
1641 .collect()
1642 })
1643 .unwrap_or_default();
1644 serde_json::json!({
1645 "name": t["name"],
1646 "sections": sections,
1647 "fields": fields,
1648 "propagating_relationships": t["propagating_relationships"],
1649 })
1650 })
1651 .collect();
1652 obj.insert(
1653 "types_summary".into(),
1654 serde_json::Value::Array(types_summary),
1655 );
1656 }
1657
1658 payload
1659}
1660
1661fn describe_metadata_field(field: &memstead_schema::MetadataFieldDef) -> String {
1663 let type_str = match field.field_type {
1664 FieldType::String => "String",
1665 FieldType::Number => "Number",
1666 FieldType::Date => "Date",
1667 FieldType::Boolean => "Boolean",
1668 };
1669
1670 let mut flags: Vec<&str> = Vec::new();
1671 if field.optional {
1672 flags.push("optional");
1673 } else {
1674 flags.push("required");
1675 }
1676 if field.init_timestamp {
1677 flags.push("auto-init");
1678 }
1679 if field.auto_timestamp {
1680 flags.push("auto-update");
1681 }
1682 match field.serialization {
1683 Serialization::CsvArray => flags.push("csv array"),
1684 Serialization::OmitWhenFalsy => flags.push("omit when falsy"),
1685 Serialization::Default => {}
1686 }
1687
1688 let mut extras: Vec<String> = Vec::new();
1689 if let Some(values) = &field.enum_values {
1690 extras.push(format!("enum: {}", values.join(", ")));
1691 }
1692 if let Some(default) = &field.default_value {
1693 extras.push(format!("default: {default}"));
1694 }
1695 let filterable_str = match field.filterable {
1696 Filterable::None => None,
1697 Filterable::Equality => Some("filterable: equality"),
1698 Filterable::Range => Some("filterable: range"),
1699 };
1700 if let Some(f) = filterable_str {
1701 extras.push(f.to_string());
1702 }
1703
1704 let extras_str = if extras.is_empty() {
1705 String::new()
1706 } else {
1707 format!(" — {}", extras.join(" — "))
1708 };
1709
1710 format!(
1711 "**{key}**: {type_str} ({flags}){extras_str}",
1712 key = field.key,
1713 flags = flags.join(", "),
1714 )
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719 use super::*;
1720 use crate::{Entity, EntityId, ListResult, SearchResult};
1721 use indexmap::IndexMap;
1722 use std::collections::HashMap;
1723
1724 fn make_hit(id: &str, title: &str, entity_type: &str, sections: &[(&str, &str)]) -> SearchHit {
1725 SearchHit {
1726 id: EntityId(id.to_string()),
1727 title: title.to_string(),
1728 mem: id.split("--").next().unwrap_or("").to_string(),
1729 entity_type: entity_type.to_string(),
1730 stub: false,
1731 score: 1.0,
1732 tokens: 10,
1733 snippet: None,
1734 sections: sections
1735 .iter()
1736 .map(|(k, v)| (k.to_string(), v.to_string()))
1737 .collect(),
1738 score_breakdown: None,
1739 matched_terms: None,
1740 expansion: None,
1741 summary: None,
1744 }
1745 }
1746
1747 fn search_result(hits: Vec<SearchHit>) -> SearchResult {
1748 let returned = hits.len();
1749 let total_tokens = hits.iter().map(|h| h.tokens).sum();
1750 SearchResult {
1751 total: returned,
1752 returned,
1753 offset: 0,
1754 total_tokens,
1755 hits,
1756 facets: None,
1757 warnings: vec![],
1758 }
1759 }
1760
1761 fn list_result(hits: Vec<SearchHit>) -> ListResult {
1762 let returned = hits.len();
1763 ListResult {
1764 total: returned,
1765 returned,
1766 offset: 0,
1767 total_tokens: hits.iter().map(|h| h.tokens).sum(),
1768 hits,
1769 warnings: vec![],
1770 }
1771 }
1772
1773 fn test_entity() -> Entity {
1774 Entity {
1775 id: EntityId("specs--test-entity".to_string()),
1776 title: "Test Entity".to_string(),
1777 entity_type: "spec".to_string(),
1778 mem: "specs".to_string(),
1779 file_path: "test-entity.md".to_string(),
1780 metadata: IndexMap::new(),
1781 sections: IndexMap::from([
1782 ("identity".to_string(), "A test entity for unit tests.".to_string()),
1783 ("purpose".to_string(), "Validates render logic.".to_string()),
1784 ("specifies".to_string(), "Long section content that adds significant token weight to the full entity estimate.".to_string()),
1785 ]),
1786 relationships: vec![],
1787 content_hash: "abc123".to_string(),
1788 stub: false,
1789 stub_kind: None,
1790 heading_spans: std::collections::HashMap::new(),
1791 }
1792 }
1793
1794 #[test]
1795 fn section_key_to_heading_basic() {
1796 assert_eq!(section_key_to_heading("identity"), "Identity");
1797 assert_eq!(section_key_to_heading("current_state"), "Current state");
1798 }
1799
1800 #[test]
1801 fn render_uses_schema_declared_heading_for_non_trivial_casing() {
1802 let mut sections: IndexMap<String, String> = IndexMap::new();
1808 sections.insert("claim_a".to_string(), "Body A.".to_string());
1809 sections.insert("claim_b".to_string(), "Body B.".to_string());
1810
1811 let entity = Entity {
1812 id: EntityId("ingest--example".to_string()),
1813 title: "Example".to_string(),
1814 entity_type: "inconsistency".to_string(),
1815 mem: "ingest".to_string(),
1816 file_path: "example.md".to_string(),
1817 metadata: IndexMap::new(),
1818 sections,
1819 relationships: vec![],
1820 content_hash: "h".to_string(),
1821 stub: false,
1822 stub_kind: None,
1823 heading_spans: std::collections::HashMap::new(),
1824 };
1825
1826 let md = render_entity_markdown(&entity, None);
1827 assert!(
1828 md.contains("## Claim A"),
1829 "expected schema-declared `## Claim A` heading; got:\n{md}"
1830 );
1831 assert!(
1832 md.contains("## Claim B"),
1833 "expected schema-declared `## Claim B` heading; got:\n{md}"
1834 );
1835 assert!(
1837 !md.contains("## Claim a"),
1838 "renderer must not fall back to key-derivation when the \
1839 schema declares a heading; got:\n{md}"
1840 );
1841 }
1842
1843 #[test]
1844 fn render_falls_back_to_key_derivation_for_unknown_types() {
1845 let mut sections: IndexMap<String, String> = IndexMap::new();
1849 sections.insert("identity".to_string(), "body".to_string());
1850
1851 let entity = Entity {
1852 id: EntityId("custom--example".to_string()),
1853 title: "Example".to_string(),
1854 entity_type: "not-a-builtin-type".to_string(),
1855 mem: "custom".to_string(),
1856 file_path: "example.md".to_string(),
1857 metadata: IndexMap::new(),
1858 sections,
1859 relationships: vec![],
1860 content_hash: "h".to_string(),
1861 stub: false,
1862 stub_kind: None,
1863 heading_spans: std::collections::HashMap::new(),
1864 };
1865
1866 let md = render_entity_markdown(&entity, None);
1867 assert!(
1868 md.contains("## Identity"),
1869 "fallback derivation must produce `## Identity`; got:\n{md}"
1870 );
1871 }
1872
1873 #[test]
1880 fn render_entity_sections_follow_indexmap_insertion_order() {
1881 let mut sections: IndexMap<String, String> = IndexMap::new();
1882 sections.insert("specifies".to_string(), "S content.".to_string());
1883 sections.insert("purpose".to_string(), "P content.".to_string());
1884 sections.insert("identity".to_string(), "I content.".to_string());
1885
1886 let entity = Entity {
1887 id: EntityId("specs--order-test".to_string()),
1888 title: "Order Test".to_string(),
1889 entity_type: "spec".to_string(),
1890 mem: "specs".to_string(),
1891 file_path: "order-test.md".to_string(),
1892 metadata: IndexMap::new(),
1893 sections,
1894 relationships: vec![],
1895 content_hash: "abc123".to_string(),
1896 stub: false,
1897 stub_kind: None,
1898 heading_spans: std::collections::HashMap::new(),
1899 };
1900
1901 let md = render_entity_markdown(&entity, None);
1902 let specifies_pos = md.find("## Specifies").expect("## Specifies must appear");
1903 let purpose_pos = md.find("## Purpose").expect("## Purpose must appear");
1904 let identity_pos = md.find("## Identity").expect("## Identity must appear");
1905
1906 assert!(
1907 specifies_pos < purpose_pos,
1908 "Specifies (inserted first) must render before Purpose; got:\n{md}"
1909 );
1910 assert!(
1911 purpose_pos < identity_pos,
1912 "Purpose (inserted second) must render before Identity; got:\n{md}"
1913 );
1914 }
1915
1916 #[test]
1922 fn tokens_reflect_filtered_output() {
1923 let entity = test_entity();
1924
1925 let full = render_entity_markdown(&entity, None);
1927 assert!(full.contains("_tokens:"), "should have _tokens");
1928 assert!(
1929 !full.contains("_tokens_unfiltered_body:"),
1930 "should NOT have _tokens_unfiltered_body when unfiltered"
1931 );
1932 assert!(
1933 !full.contains("_tokens_full:"),
1934 "old _tokens_full name must not survive — rename is one-way"
1935 );
1936
1937 let filtered = render_entity_markdown(&entity, Some(&["identity".to_string()]));
1939 assert!(filtered.contains("_tokens:"), "should have _tokens");
1940 assert!(
1941 filtered.contains("_tokens_unfiltered_body:"),
1942 "should have _tokens_unfiltered_body when filtered"
1943 );
1944 assert!(
1945 !filtered.contains("_tokens_full:"),
1946 "old _tokens_full name must not survive — rename is one-way"
1947 );
1948
1949 let full_tokens: usize = full
1951 .lines()
1952 .find(|l| l.starts_with("_tokens:"))
1953 .unwrap()
1954 .trim_start_matches("_tokens: ")
1955 .parse()
1956 .unwrap();
1957 let filtered_tokens: usize = filtered
1958 .lines()
1959 .find(|l| l.starts_with("_tokens:"))
1960 .unwrap()
1961 .trim_start_matches("_tokens: ")
1962 .parse()
1963 .unwrap();
1964 let tokens_unfiltered_body: usize = filtered
1965 .lines()
1966 .find(|l| l.starts_with("_tokens_unfiltered_body:"))
1967 .unwrap()
1968 .trim_start_matches("_tokens_unfiltered_body: ")
1969 .parse()
1970 .unwrap();
1971
1972 assert!(
1973 filtered_tokens < full_tokens,
1974 "filtered _tokens ({filtered_tokens}) should be less than full _tokens ({full_tokens})"
1975 );
1976 assert!(
1977 tokens_unfiltered_body >= full_tokens,
1978 "_tokens_unfiltered_body ({tokens_unfiltered_body}) should be >= full render _tokens ({full_tokens})"
1979 );
1980 }
1981
1982 #[test]
1987 fn render_search_uses_first_required_section_for_spec() {
1988 let hit = make_hit(
1989 "specs--demo",
1990 "Demo Spec",
1991 "spec",
1992 &[
1993 ("identity", "A demo spec."),
1994 ("purpose", "Verifies rendering."),
1995 ],
1996 );
1997 let out = render_search_markdown(&search_result(vec![hit]), 0);
1998 assert!(
1999 out.contains("**Identity**: A demo spec."),
2000 "expected Identity line for spec hit, got:\n{out}"
2001 );
2002 }
2003
2004 #[test]
2005 fn render_search_uses_first_required_section_for_memo() {
2006 let hit = make_hit(
2007 "memos--d1",
2008 "Memo One",
2009 "memo",
2010 &[("claim", "Some claim."), ("context", "Some context.")],
2011 );
2012 let out = render_search_markdown(&search_result(vec![hit]), 0);
2013 assert!(
2014 out.contains("**Claim**: Some claim."),
2015 "expected Claim line for memo hit, got:\n{out}"
2016 );
2017 assert!(
2018 !out.contains("**Identity**"),
2019 "memo hit must not render Identity label"
2020 );
2021 assert!(
2022 !out.contains("**Purpose**"),
2023 "memo hit must not render Purpose label"
2024 );
2025 }
2026
2027 #[test]
2028 fn render_search_uses_first_required_section_for_concept() {
2029 let hit = make_hit(
2030 "concepts--thing",
2031 "Thing",
2032 "concept",
2033 &[("definition", "A thing."), ("explanation", "Details.")],
2034 );
2035 let out = render_search_markdown(&search_result(vec![hit]), 0);
2036 assert!(
2037 out.contains("**Definition**: A thing."),
2038 "expected Definition line for concept hit, got:\n{out}"
2039 );
2040 }
2041
2042 #[test]
2043 fn render_search_missing_summary_section_shows_dash() {
2044 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2046 let out = render_search_markdown(&search_result(vec![hit]), 0);
2047 assert!(
2048 out.contains("**Claim**: —"),
2049 "expected Claim dash fallback, got:\n{out}"
2050 );
2051 }
2052
2053 #[test]
2054 fn render_search_mixes_schemas_in_one_result() {
2055 let spec_hit = make_hit(
2056 "specs--s1",
2057 "Spec One",
2058 "spec",
2059 &[("identity", "Spec body.")],
2060 );
2061 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2062 let out = render_search_markdown(&search_result(vec![spec_hit, memo_hit]), 0);
2063 assert!(
2064 out.contains("**Identity**: Spec body."),
2065 "spec hit should still render Identity, got:\n{out}"
2066 );
2067 assert!(
2068 out.contains("**Claim**: Memo claim."),
2069 "memo hit should render Claim in the same output, got:\n{out}"
2070 );
2071 }
2072
2073 #[test]
2074 fn render_search_unknown_schema_shows_summary_dash() {
2075 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2076 let out = render_search_markdown(&search_result(vec![hit]), 0);
2077 assert!(
2078 out.contains("**Summary**: —"),
2079 "unknown schema should render Summary dash, got:\n{out}"
2080 );
2081 }
2082
2083 #[test]
2084 fn summary_pair_falls_back_when_schema_has_no_required_sections() {
2085 use memstead_schema::{SectionDef, TypeDefinition};
2086
2087 let schema = TypeDefinition {
2088 name: "spec".to_string(),
2089 description: "test".to_string(),
2090 when_to_use: "test".to_string(),
2091 boundaries: vec![],
2092 examples: vec![],
2093 system_message: None,
2094 sections: vec![SectionDef {
2095 key: "note".to_string(),
2096 heading: "Note".to_string(),
2097 required: false,
2098 search_weight: 1.0,
2099 catch_all: false,
2100 write_rules: vec![],
2101 description: None,
2102 }],
2103 metadata_fields: vec![],
2104 title_weight: 1.0,
2105 text_fields: vec![],
2106 hierarchy_relationship: "PART_OF".to_string(),
2107 edge_weight_overrides: indexmap::IndexMap::new(),
2108 edge_weights: indexmap::IndexMap::new(),
2109 propagating_relationships: vec![],
2110 updatable_fields: vec![],
2111 health_required_fields: vec![],
2112 staleness_threshold_days: 90,
2113 write_rules: vec![],
2114 required_outgoing: vec![],
2115 };
2116
2117 let mut sections = HashMap::new();
2118 sections.insert("note".to_string(), "a note".to_string());
2119 assert_eq!(
2120 summary_pair(Some(&schema), §ions),
2121 ("Note".to_string(), "a note".to_string()),
2122 );
2123
2124 assert_eq!(
2125 summary_pair(Some(&schema), &HashMap::new()),
2126 ("Note".to_string(), "—".to_string()),
2127 );
2128 }
2129
2130 #[test]
2135 fn render_list_uses_first_required_section_for_spec() {
2136 let hit = make_hit(
2137 "specs--demo",
2138 "Demo Spec",
2139 "spec",
2140 &[
2141 ("identity", "A demo spec."),
2142 ("purpose", "Verifies rendering."),
2143 ],
2144 );
2145 let out = render_list_markdown(&list_result(vec![hit]));
2146 assert!(
2147 out.contains("**Identity**: A demo spec."),
2148 "expected Identity line for spec hit, got:\n{out}"
2149 );
2150 }
2151
2152 #[test]
2153 fn render_list_uses_first_required_section_for_memo() {
2154 let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
2155 let out = render_list_markdown(&list_result(vec![hit]));
2156 assert!(
2157 out.contains("**Claim**: Some claim."),
2158 "expected Claim line for memo hit, got:\n{out}"
2159 );
2160 assert!(
2161 !out.contains("**Identity**"),
2162 "memo hit must not render Identity label in list output"
2163 );
2164 }
2165
2166 #[test]
2167 fn render_list_uses_first_required_section_for_concept() {
2168 let hit = make_hit(
2169 "concepts--thing",
2170 "Thing",
2171 "concept",
2172 &[("definition", "A thing.")],
2173 );
2174 let out = render_list_markdown(&list_result(vec![hit]));
2175 assert!(
2176 out.contains("**Definition**: A thing."),
2177 "expected Definition line for concept hit, got:\n{out}"
2178 );
2179 }
2180
2181 #[test]
2182 fn render_list_missing_summary_section_shows_dash() {
2183 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2184 let out = render_list_markdown(&list_result(vec![hit]));
2185 assert!(
2186 out.contains("**Claim**: —"),
2187 "expected Claim dash fallback in list output, got:\n{out}"
2188 );
2189 }
2190
2191 #[test]
2192 fn render_list_mixes_schemas_in_one_result() {
2193 let spec_hit = make_hit(
2194 "specs--s1",
2195 "Spec One",
2196 "spec",
2197 &[("identity", "Spec body.")],
2198 );
2199 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2200 let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
2201 assert!(
2202 out.contains("**Identity**: Spec body."),
2203 "spec hit should still render Identity in list output, got:\n{out}"
2204 );
2205 assert!(
2206 out.contains("**Claim**: Memo claim."),
2207 "memo hit should render Claim in list output, got:\n{out}"
2208 );
2209 }
2210
2211 #[test]
2212 fn render_list_unknown_schema_shows_summary_dash() {
2213 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2214 let out = render_list_markdown(&list_result(vec![hit]));
2215 assert!(
2216 out.contains("**Summary**: —"),
2217 "unknown schema should render Summary dash in list output, got:\n{out}"
2218 );
2219 }
2220
2221 #[test]
2226 fn summary_pair_for_spec_returns_identity() {
2227 let schema = type_by_name("spec");
2228 let mut sections = HashMap::new();
2229 sections.insert("identity".to_string(), "A demo spec.".to_string());
2230 assert_eq!(
2231 summary_pair(schema.as_deref(), §ions),
2232 ("Identity".to_string(), "A demo spec.".to_string()),
2233 );
2234 }
2235
2236 #[test]
2237 fn summary_pair_for_memo_returns_claim() {
2238 let schema = type_by_name("memo");
2239 let mut sections = HashMap::new();
2240 sections.insert("claim".to_string(), "Memos matter.".to_string());
2241 assert_eq!(
2242 summary_pair(schema.as_deref(), §ions),
2243 ("Claim".to_string(), "Memos matter.".to_string()),
2244 );
2245 }
2246
2247 #[test]
2248 fn summary_pair_missing_section_returns_dash() {
2249 let schema = type_by_name("memo");
2250 assert_eq!(
2251 summary_pair(schema.as_deref(), &HashMap::new()),
2252 ("Claim".to_string(), "—".to_string()),
2253 );
2254 }
2255
2256 #[test]
2257 fn summary_pair_unknown_schema_returns_summary_dash() {
2258 assert_eq!(
2259 summary_pair(None, &HashMap::new()),
2260 ("Summary".to_string(), "—".to_string()),
2261 );
2262 }
2263
2264 #[test]
2269 fn envelope_serializes_summary_fields() {
2270 let hit = make_hit(
2271 "memos--d1",
2272 "Memo One",
2273 "memo",
2274 &[("claim", "Memos matter.")],
2275 );
2276 let result = search_result(vec![hit]);
2277 let envelope = build_search_envelope(&result, 0);
2278 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2279
2280 assert_eq!(value["_total"], 1);
2284 assert_eq!(value["_returned"], 1);
2285 assert_eq!(value["_offset"], 0);
2286 assert!(
2288 value.get("warnings").is_none(),
2289 "empty warnings must be elided, got: {value}"
2290 );
2291
2292 let hit0 = &value["hits"][0];
2293 assert_eq!(hit0["summary_heading"], "Claim");
2294 assert_eq!(hit0["summary_value"], "Memos matter.");
2295 assert_eq!(hit0["id"], "memos--d1");
2297 assert_eq!(hit0["title"], "Memo One");
2298 assert_eq!(hit0["entity_type"], "memo");
2299 assert_eq!(hit0["mem"], "memos");
2300 assert_eq!(hit0["stub"], false);
2301 assert_eq!(hit0["tokens"], 10);
2302 assert!(hit0["sections"].is_object());
2303 }
2304
2305 #[test]
2306 fn envelope_roundtrips_through_structured_content() {
2307 let spec_hit = make_hit(
2310 "specs--s1",
2311 "Spec One",
2312 "spec",
2313 &[("identity", "Spec body.")],
2314 );
2315 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2316 let result = search_result(vec![spec_hit, memo_hit]);
2317 let envelope = build_search_envelope(&result, 0);
2318 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2319
2320 let hits = value["hits"].as_array().expect("hits must be array");
2321 assert_eq!(hits.len(), 2);
2322 assert_eq!(hits[0]["summary_heading"], "Identity");
2323 assert_eq!(hits[0]["summary_value"], "Spec body.");
2324 assert_eq!(hits[1]["summary_heading"], "Claim");
2325 assert_eq!(hits[1]["summary_value"], "Memo claim.");
2326 }
2327
2328 #[test]
2329 fn list_envelope_includes_total_tokens() {
2330 let hit = make_hit(
2331 "concepts--c1",
2332 "Thing",
2333 "concept",
2334 &[("definition", "A thing.")],
2335 );
2336 let result = list_result(vec![hit]);
2337 let envelope = build_list_envelope(&result);
2338 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2339
2340 assert_eq!(value["_total"], 1);
2342 assert_eq!(value["_total_tokens"], 10);
2343 assert!(value.get("total").is_none(), "unprefixed keys retired");
2344 assert_eq!(value["hits"][0]["summary_heading"], "Definition");
2345 assert_eq!(value["hits"][0]["summary_value"], "A thing.");
2346 }
2347
2348 #[test]
2349 fn envelope_emits_warnings_when_present() {
2350 let mut result = search_result(vec![]);
2351 result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
2354 field: "foo".to_string(),
2355 }];
2356 let envelope = build_search_envelope(&result, 0);
2357 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2358 assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
2359 assert_eq!(value["warnings"][0]["details"]["field"], "foo");
2360 assert!(
2361 value["warnings"][0]["message"]
2362 .as_str()
2363 .is_some_and(|m| m.contains("not filterable"))
2364 );
2365 }
2366
2367 fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
2372 TermMatch {
2373 field: field.to_string(),
2374 snippet: snippet.to_string(),
2375 heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
2376 }
2377 }
2378
2379 fn sample_facets() -> Facets {
2380 use crate::ops::SubsectionFacet;
2381 Facets {
2382 by_type: HashMap::from([
2383 ("spec".to_string(), 7),
2384 ("memo".to_string(), 3),
2385 ("decision".to_string(), 2),
2386 ]),
2387 by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
2388 by_level: HashMap::from([("high".to_string(), 4)]),
2389 by_status: HashMap::from([("active".to_string(), 6)]),
2390 by_confidence: HashMap::from([("medium".to_string(), 3)]),
2391 by_subsection: vec![
2392 SubsectionFacet {
2393 path: vec!["specifies".to_string(), "Response Shapes".to_string()],
2394 count: 4,
2395 },
2396 SubsectionFacet {
2397 path: vec!["purpose".to_string(), "Rationale".to_string()],
2398 count: 2,
2399 },
2400 ],
2401 by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
2402 }
2403 }
2404
2405 #[test]
2406 fn render_search_emits_matched_terms_line() {
2407 let mut hit = make_hit(
2408 "specs--e1",
2409 "Entity One",
2410 "spec",
2411 &[("identity", "Body text.")],
2412 );
2413 hit.matched_terms = Some(HashMap::from([
2414 (
2415 "entity".to_string(),
2416 vec![
2417 tm("title", "...entity...", None),
2418 tm("purpose", "...entity...", None),
2419 tm("purpose", "...entity two...", None),
2420 ],
2421 ),
2422 ("one".to_string(), vec![tm("title", "...one...", None)]),
2423 ]));
2424 let out = render_search_markdown(&search_result(vec![hit]), 0);
2425 assert!(
2426 out.contains("**Matched terms:**"),
2427 "missing Matched terms line; got:\n{out}"
2428 );
2429 assert!(
2430 out.contains("`entity` (purpose×2, title×1)"),
2431 "entity term grouping wrong; got:\n{out}"
2432 );
2433 assert!(
2434 out.contains("`one` (title×1)"),
2435 "one term grouping wrong; got:\n{out}"
2436 );
2437 }
2438
2439 #[test]
2440 fn render_search_emits_score_breakdown_line() {
2441 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2442 hit.score_breakdown = Some(ScoreBreakdown {
2443 bm25: 2.5,
2444 title_boost: 2.0,
2445 field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
2446 expansion_decay: Some(0.5),
2447 });
2448 let out = render_search_markdown(&search_result(vec![hit]), 0);
2449 assert!(
2450 out.contains(
2451 "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
2452 ),
2453 "score breakdown line wrong; got:\n{out}"
2454 );
2455 }
2456
2457 #[test]
2458 fn render_search_omits_expansion_decay_when_none() {
2459 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2460 hit.score_breakdown = Some(ScoreBreakdown {
2461 bm25: 1.5,
2462 title_boost: 1.0,
2463 field_weights: HashMap::new(),
2464 expansion_decay: None,
2465 });
2466 let out = render_search_markdown(&search_result(vec![hit]), 0);
2467 assert!(
2468 out.contains("**Score:** bm25 1.5 + title 1.0"),
2469 "base score wrong; got:\n{out}"
2470 );
2471 assert!(
2472 !out.contains("expansion_decay"),
2473 "expansion_decay must be absent when None; got:\n{out}"
2474 );
2475 }
2476
2477 #[test]
2478 fn render_search_emits_heading_path_line() {
2479 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2480 hit.matched_terms = Some(HashMap::from([(
2481 "x".to_string(),
2482 vec![
2483 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
2484 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
2486 ],
2487 )]));
2488 let out = render_search_markdown(&search_result(vec![hit]), 0);
2489 assert!(
2490 out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
2491 "heading path line wrong; got:\n{out}"
2492 );
2493 }
2494
2495 #[test]
2496 fn render_search_emits_expansion_line() {
2497 let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
2498 hit.expansion = Some(ExpansionInfo {
2499 of: EntityId("specs--seed".to_string()),
2500 via_edge: "refines".to_string(),
2501 depth: 1,
2502 });
2503 let out = render_search_markdown(&search_result(vec![hit]), 0);
2504 assert!(
2505 out.contains("**Expansion:** from `specs--seed` via `refines` (depth 1)"),
2506 "expansion line wrong; got:\n{out}"
2507 );
2508 }
2509
2510 #[test]
2511 fn render_search_emits_facets_block() {
2512 let mut result = search_result(vec![]);
2513 result.facets = Some(sample_facets());
2514 let out = render_search_markdown(&result, 0);
2515 assert!(
2516 out.contains("## Facets"),
2517 "facets header missing; got:\n{out}"
2518 );
2519 assert!(
2520 out.contains("- **by_type:** spec=7, memo=3, decision=2"),
2521 "by_type bucket wrong; got:\n{out}"
2522 );
2523 assert!(
2524 out.contains("- **by_mem:** specs=10, memos=2"),
2525 "by_mem bucket wrong; got:\n{out}"
2526 );
2527 assert!(
2528 out.contains("- **by_level:** high=4"),
2529 "by_level bucket wrong; got:\n{out}"
2530 );
2531 assert!(
2532 out.contains("- **by_status:** active=6"),
2533 "by_status bucket wrong; got:\n{out}"
2534 );
2535 assert!(
2536 out.contains("- **by_confidence:** medium=3"),
2537 "by_confidence bucket wrong; got:\n{out}"
2538 );
2539 assert!(
2540 out.contains("- **by_expansion:** primary=8, expanded=4"),
2541 "by_expansion bucket wrong; got:\n{out}"
2542 );
2543 assert!(
2544 out.contains("- **by_subsection:**"),
2545 "by_subsection header missing; got:\n{out}"
2546 );
2547 assert!(
2548 out.contains("`specifies › Response Shapes`: 4"),
2549 "subsection facet wrong; got:\n{out}"
2550 );
2551 }
2552
2553 #[test]
2554 fn render_search_omits_facets_block_when_all_empty() {
2555 let mut result = search_result(vec![]);
2556 result.facets = Some(Facets::default());
2557 let out = render_search_markdown(&result, 0);
2558 assert!(
2559 !out.contains("## Facets"),
2560 "empty facets must not emit header; got:\n{out}"
2561 );
2562 }
2563
2564 #[test]
2568 fn search_markdown_covers_every_sidecar_field() {
2569 let mut hit = make_hit(
2570 "specs--e1",
2571 "Entity One",
2572 "spec",
2573 &[("identity", "Body text.")],
2574 );
2575 hit.matched_terms = Some(HashMap::from([(
2576 "entity".to_string(),
2577 vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
2578 )]));
2579 hit.score_breakdown = Some(ScoreBreakdown {
2580 bm25: 1.5,
2581 title_boost: 1.0,
2582 field_weights: HashMap::from([("body".to_string(), 0.4)]),
2583 expansion_decay: Some(0.5),
2584 });
2585 hit.expansion = Some(ExpansionInfo {
2586 of: EntityId("specs--seed".to_string()),
2587 via_edge: "refines".to_string(),
2588 depth: 2,
2589 });
2590
2591 let mut result = search_result(vec![hit]);
2592 result.facets = Some(sample_facets());
2593
2594 let out = render_search_markdown(&result, 0);
2595 for marker in [
2596 "## Facets",
2597 "- **by_type:**",
2598 "- **by_mem:**",
2599 "- **by_level:**",
2600 "- **by_status:**",
2601 "- **by_confidence:**",
2602 "- **by_expansion:**",
2603 "- **by_subsection:**",
2604 "**Matched terms:**",
2605 "**Score:**",
2606 "**Heading path:**",
2607 "**Expansion:**",
2608 ] {
2609 assert!(
2610 out.contains(marker),
2611 "lockstep marker `{marker}` missing from search markdown; \
2612 update render_search_markdown when adding sidecar fields. got:\n{out}"
2613 );
2614 }
2615 }
2616
2617 #[test]
2624 fn build_entity_envelope_source_field_reads_edge_source() {
2625 let mut entity = test_entity();
2626 let body_link_target = EntityId("specs--body-link-target".to_string());
2627 let explicit_target = EntityId("specs--explicit-target".to_string());
2628 entity.relationships = vec![
2629 crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
2630 crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
2631 ];
2632
2633 let edges = vec![
2634 crate::store::Edge {
2635 rel_type: "REFERENCES".to_string(),
2636 target: body_link_target.clone(),
2637 source: crate::store::EdgeSource::BodyLink,
2638 },
2639 crate::store::Edge {
2640 rel_type: "USES".to_string(),
2641 target: explicit_target.clone(),
2642 source: crate::store::EdgeSource::Explicit,
2643 },
2644 ];
2645
2646 let env = build_entity_envelope(&entity, 0, None, None, None, &edges);
2647 let relationships = env["relationships"].as_array().expect("array");
2648 let refs = relationships
2649 .iter()
2650 .find(|r| r["rel_type"] == "REFERENCES")
2651 .expect("REFERENCES present");
2652 assert_eq!(
2653 refs["source"], "body_link",
2654 "alias-synthesised edge must label body_link"
2655 );
2656 let uses = relationships
2657 .iter()
2658 .find(|r| r["rel_type"] == "USES")
2659 .expect("USES present");
2660 assert_eq!(
2661 uses["source"], "explicit",
2662 "explicit-authored edge must label explicit"
2663 );
2664 }
2665
2666 #[test]
2671 fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
2672 let mut entity = test_entity();
2673 let target = EntityId("specs--unmapped".to_string());
2674 entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
2675 let edges: Vec<crate::store::Edge> = Vec::new();
2676 let env = build_entity_envelope(&entity, 0, None, None, None, &edges);
2677 let relationships = env["relationships"].as_array().expect("array");
2678 assert_eq!(relationships[0]["source"], "explicit");
2679 }
2680
2681 #[test]
2687 fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
2688 use crate::entity::MetadataValue;
2689 let mut entity = test_entity();
2690 entity.entity_type = "contract".to_string();
2691 entity.metadata = IndexMap::from([
2693 ("level".to_string(), MetadataValue::String("M0".to_string())),
2694 (
2695 "stability".to_string(),
2696 MetadataValue::String("stable".to_string()),
2697 ),
2698 (
2699 "created_date".to_string(),
2700 MetadataValue::String("2026-01-01".to_string()),
2701 ),
2702 (
2703 "last_modified".to_string(),
2704 MetadataValue::String("2026-05-19".to_string()),
2705 ),
2706 (
2707 "protocol".to_string(),
2708 MetadataValue::String("https".to_string()),
2709 ),
2710 (
2711 "version".to_string(),
2712 MetadataValue::String("0.1.0".to_string()),
2713 ),
2714 (
2715 "deprecation_status".to_string(),
2716 MetadataValue::String("none".to_string()),
2717 ),
2718 ]);
2719
2720 let env = build_entity_envelope(&entity, 0, None, None, None, &[]);
2721
2722 assert!(
2725 env.get("level").is_none(),
2726 "level must not be hoisted top-level"
2727 );
2728 assert!(
2729 env.get("stability").is_none(),
2730 "stability must not be hoisted"
2731 );
2732 assert!(
2733 env.get("created_date").is_none(),
2734 "created_date must not be hoisted"
2735 );
2736 assert!(
2737 env.get("last_modified").is_none(),
2738 "last_modified must not be hoisted"
2739 );
2740 assert_eq!(env["type"], "contract");
2742
2743 let metadata = env["metadata"].as_object().expect("metadata map");
2745 assert_eq!(metadata["level"], "M0");
2746 assert_eq!(metadata["stability"], "stable");
2747 assert_eq!(metadata["created_date"], "2026-01-01");
2748 assert_eq!(metadata["last_modified"], "2026-05-19");
2749 assert_eq!(metadata["protocol"], "https");
2750 assert_eq!(metadata["version"], "0.1.0");
2751 assert_eq!(metadata["deprecation_status"], "none");
2752
2753 for k in metadata.keys() {
2756 assert!(
2757 !k.starts_with('_'),
2758 "metadata map must not carry underscore-prefixed key `{k}`"
2759 );
2760 assert!(
2761 !["mem", "id", "type"].contains(&k.as_str()),
2762 "metadata map must not carry identity key `{k}` (it lives top-level)"
2763 );
2764 }
2765 }
2766
2767 #[test]
2771 fn build_entity_envelope_stub_carries_empty_metadata_map() {
2772 let mut entity = test_entity();
2773 entity.stub = true;
2774 entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
2775 entity.metadata = IndexMap::new();
2776 let env = build_entity_envelope(&entity, 0, None, None, None, &[]);
2777 let metadata = env["metadata"]
2778 .as_object()
2779 .expect("metadata key present even on stubs");
2780 assert!(metadata.is_empty(), "stub metadata map must be empty");
2781 }
2782
2783 #[test]
2790 fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
2791 use crate::entity::MetadataValue;
2792 let mut entity = test_entity();
2793 entity.metadata = IndexMap::from([
2794 (
2795 "sections".to_string(),
2796 MetadataValue::String("user-supplied-shadow".to_string()),
2797 ),
2798 (
2799 "relationships".to_string(),
2800 MetadataValue::String("also-shadowed".to_string()),
2801 ),
2802 ]);
2803 let env = build_entity_envelope(&entity, 0, None, None, None, &[]);
2804 assert!(
2806 env["sections"].is_object(),
2807 "top-level sections stays a map"
2808 );
2809 assert!(
2810 env["relationships"].is_array(),
2811 "top-level relationships stays an array"
2812 );
2813 let metadata = env["metadata"].as_object().expect("metadata map");
2815 assert_eq!(metadata["sections"], "user-supplied-shadow");
2816 assert_eq!(metadata["relationships"], "also-shadowed");
2817 }
2818
2819 #[test]
2823 fn build_entity_envelope_unfiltered_body_token_field_name() {
2824 let entity = test_entity();
2825 let env_filtered = build_entity_envelope(&entity, 10, Some(42), None, None, &[]);
2827 assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
2828 assert!(
2829 env_filtered.get("_tokens_full").is_none(),
2830 "_tokens_full must not survive — rename is one-way"
2831 );
2832 let env_unfiltered = build_entity_envelope(&entity, 10, None, None, None, &[]);
2834 assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
2835 assert!(env_unfiltered.get("_tokens_full").is_none());
2836 }
2837
2838 fn software_schema() -> Arc<Schema> {
2846 memstead_schema::builtins::load_builtin_schemas()
2847 .expect("builtins load")
2848 .into_iter()
2849 .find(|s| s.manifest.name == "software")
2850 .expect("software schema is a builtin")
2851 }
2852
2853 #[test]
2854 fn schema_verbosity_wire_round_trips() {
2855 assert_eq!(
2856 SchemaVerbosity::from_wire("full"),
2857 Some(SchemaVerbosity::Full)
2858 );
2859 assert_eq!(
2860 SchemaVerbosity::from_wire("lite"),
2861 Some(SchemaVerbosity::Lite)
2862 );
2863 assert_eq!(SchemaVerbosity::from_wire("brief"), None);
2864 assert_eq!(SchemaVerbosity::from_wire(""), None);
2865 assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
2866 assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
2867 assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
2868 }
2869
2870 #[test]
2874 fn first_party_origin_is_labelled_and_keeps_prose() {
2875 let schema = software_schema();
2876 let full = build_schema_payload(
2877 &schema,
2878 vec!["v".into()],
2879 SchemaVerbosity::Full,
2880 OriginClass::FirstParty,
2881 );
2882 assert_eq!(full["origin"], "first-party");
2883 assert!(full["description"].is_string());
2885 let t = &full["types"].as_array().unwrap()[0];
2886 assert!(t.get("system_context").is_some());
2887 assert!(t.get("writing_guidance").is_some());
2888
2889 let lite = build_schema_payload(
2891 &schema,
2892 vec!["v".into()],
2893 SchemaVerbosity::Lite,
2894 OriginClass::FirstParty,
2895 );
2896 assert_eq!(lite["origin"], "first-party");
2897 }
2898
2899 #[test]
2909 fn third_party_origin_forces_structural_only_even_under_full() {
2910 let schema = software_schema();
2911 let full_requested = build_schema_payload(
2912 &schema,
2913 vec!["v".into()],
2914 SchemaVerbosity::Full,
2915 OriginClass::ThirdParty,
2916 );
2917
2918 assert_eq!(full_requested["origin"], "third-party");
2920
2921 assert!(
2924 full_requested.get("types").is_none(),
2925 "third-party omits the rich `types` array even under full"
2926 );
2927 assert!(
2928 full_requested.get("relationships").is_none(),
2929 "third-party omits the rich `relationships` array even under full"
2930 );
2931 assert!(
2932 full_requested["types_summary"].is_array(),
2933 "third-party serves the structural `types_summary` skeleton"
2934 );
2935 assert!(
2936 full_requested["relationships_summary"].is_array(),
2937 "third-party serves the structural `relationships_summary` skeleton"
2938 );
2939
2940 assert!(
2942 full_requested.get("description").is_none(),
2943 "third-party drops schema description prose"
2944 );
2945 assert!(
2946 full_requested.get("when_to_use").is_none(),
2947 "third-party drops schema when_to_use prose"
2948 );
2949 assert!(
2950 full_requested.get("default_writing_guidance").is_none(),
2951 "third-party drops default_writing_guidance prose"
2952 );
2953
2954 for t in full_requested["types_summary"].as_array().unwrap() {
2956 assert!(
2957 t.get("system_context").is_none(),
2958 "third-party drops system_context"
2959 );
2960 assert!(
2961 t.get("writing_guidance").is_none(),
2962 "third-party drops writing_guidance"
2963 );
2964 assert!(
2965 t.get("description").is_none(),
2966 "third-party drops type description"
2967 );
2968 for s in t["sections"].as_array().unwrap() {
2969 assert!(
2970 s.get("write_rules").is_none(),
2971 "third-party drops section write_rules"
2972 );
2973 }
2974 }
2975 for r in full_requested["relationships_summary"].as_array().unwrap() {
2977 assert!(
2978 r.get("description").is_none(),
2979 "third-party drops rel description"
2980 );
2981 assert!(
2982 r.get("when_to_use").is_none(),
2983 "third-party drops rel when_to_use"
2984 );
2985 }
2986
2987 let lite_requested = build_schema_payload(
2991 &schema,
2992 vec!["v".into()],
2993 SchemaVerbosity::Lite,
2994 OriginClass::ThirdParty,
2995 );
2996 assert_eq!(
2997 full_requested, lite_requested,
2998 "third-party full must collapse to the lite skeleton"
2999 );
3000 }
3001
3002 #[test]
3003 fn full_payload_carries_the_rich_arrays_and_prose() {
3004 let schema = software_schema();
3005 let full = build_schema_payload(
3006 &schema,
3007 vec!["v".into()],
3008 SchemaVerbosity::Full,
3009 OriginClass::FirstParty,
3010 );
3011
3012 assert!(full["types"].is_array(), "full has `types`");
3014 assert!(full["relationships"].is_array(), "full has `relationships`");
3015 assert!(
3016 full.get("types_summary").is_none(),
3017 "full omits `types_summary`"
3018 );
3019 assert!(
3020 full.get("relationships_summary").is_none(),
3021 "full omits `relationships_summary`"
3022 );
3023 assert!(
3024 full["description"].is_string(),
3025 "full keeps schema description"
3026 );
3027 assert!(
3028 full["when_to_use"].is_string(),
3029 "full keeps schema when_to_use"
3030 );
3031 assert_eq!(full["alias_target_rel_type"], "REFERENCES");
3032
3033 let t = &full["types"].as_array().unwrap()[0];
3035 assert!(t["description"].is_string());
3036 assert!(t.get("writing_guidance").is_some());
3037 assert!(t.get("system_context").is_some());
3038 let r = &full["relationships"].as_array().unwrap()[0];
3040 assert!(r["description"].is_string());
3041 assert!(r.get("when_to_use").is_some());
3042 assert!(r.get("default_weight").is_some());
3043 }
3044
3045 #[test]
3046 fn lite_payload_is_the_structural_skeleton_without_prose() {
3047 let schema = software_schema();
3048 let lite = build_schema_payload(
3049 &schema,
3050 vec!["v".into()],
3051 SchemaVerbosity::Lite,
3052 OriginClass::FirstParty,
3053 );
3054
3055 let types = lite["types_summary"]
3057 .as_array()
3058 .expect("lite has `types_summary`");
3059 let rels = lite["relationships_summary"]
3060 .as_array()
3061 .expect("lite has `relationships_summary`");
3062 assert!(lite.get("types").is_none(), "lite omits rich `types`");
3063 assert!(
3064 lite.get("relationships").is_none(),
3065 "lite omits rich `relationships`"
3066 );
3067
3068 assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
3071
3072 assert!(
3074 lite.get("description").is_none(),
3075 "lite drops schema description"
3076 );
3077 assert!(
3078 lite.get("when_to_use").is_none(),
3079 "lite drops schema when_to_use"
3080 );
3081 assert!(
3082 lite.get("default_writing_guidance").is_none(),
3083 "lite drops default_writing_guidance"
3084 );
3085
3086 for t in types {
3089 assert!(t["name"].is_string());
3090 let sections = t["sections"].as_array().expect("lite type has sections");
3091 for s in sections {
3092 assert!(s["key"].is_string(), "section carries its key");
3093 assert!(s["required"].is_boolean(), "section carries required flag");
3094 assert!(
3095 s.get("write_rules").is_none(),
3096 "lite section drops write_rules prose"
3097 );
3098 assert!(s.get("heading").is_none(), "lite section drops heading");
3099 }
3100 assert!(
3101 t.get("description").is_none(),
3102 "lite type drops description"
3103 );
3104 assert!(
3105 t.get("writing_guidance").is_none(),
3106 "lite type drops writing_guidance"
3107 );
3108 assert!(
3109 t.get("system_context").is_none(),
3110 "lite type drops system_context"
3111 );
3112 assert!(
3116 t.get("propagating_relationships").is_some(),
3117 "lite type keeps propagating_relationships"
3118 );
3119 if let Some(fields) = t["fields"].as_array() {
3121 for f in fields {
3122 assert!(f["name"].is_string());
3123 assert!(f["required"].is_boolean());
3124 assert!(
3125 f.get("description").is_none(),
3126 "lite field drops description"
3127 );
3128 }
3129 }
3130 }
3131
3132 for r in rels {
3135 assert!(r["name"].is_string());
3136 assert!(
3137 r.get("allowed_sources").is_some(),
3138 "lite rel has allowed_sources"
3139 );
3140 assert!(
3141 r.get("allowed_targets").is_some(),
3142 "lite rel has allowed_targets"
3143 );
3144 assert!(
3145 r.get("manual_authoring").is_some(),
3146 "lite rel keeps manual_authoring"
3147 );
3148 assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
3149 assert!(
3150 r.get("per_edge_description").is_some(),
3151 "lite rel keeps per_edge_description"
3152 );
3153 assert!(r.get("description").is_none(), "lite rel drops description");
3154 assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
3155 assert!(
3156 r.get("default_weight").is_none(),
3157 "lite rel drops default_weight"
3158 );
3159 }
3160 }
3161
3162 #[test]
3163 fn lite_is_measurably_smaller_than_full() {
3164 let schema = software_schema();
3165 let full = build_schema_payload(
3166 &schema,
3167 vec!["v".into()],
3168 SchemaVerbosity::Full,
3169 OriginClass::FirstParty,
3170 );
3171 let lite = build_schema_payload(
3172 &schema,
3173 vec!["v".into()],
3174 SchemaVerbosity::Lite,
3175 OriginClass::FirstParty,
3176 );
3177 let full_len = serde_json::to_string(&full).unwrap().len();
3178 let lite_len = serde_json::to_string(&lite).unwrap().len();
3179 assert!(
3180 lite_len * 2 < full_len,
3181 "lite ({lite_len} B) must be well under half of full ({full_len} B)"
3182 );
3183 }
3184
3185 #[test]
3186 fn lite_full_carry_the_same_type_and_rel_names() {
3187 let schema = software_schema();
3190 let full = build_schema_payload(
3191 &schema,
3192 vec!["v".into()],
3193 SchemaVerbosity::Full,
3194 OriginClass::FirstParty,
3195 );
3196 let lite = build_schema_payload(
3197 &schema,
3198 vec!["v".into()],
3199 SchemaVerbosity::Lite,
3200 OriginClass::FirstParty,
3201 );
3202
3203 let names = |arr: &serde_json::Value| -> Vec<String> {
3204 arr.as_array()
3205 .unwrap()
3206 .iter()
3207 .map(|v| v["name"].as_str().unwrap().to_string())
3208 .collect()
3209 };
3210 assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
3211 assert_eq!(
3212 names(&full["relationships"]),
3213 names(&lite["relationships_summary"])
3214 );
3215 }
3216}