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> {
433 let e = expansion?;
434 let dir = match e.via_direction {
435 crate::graph::query::TraversalDirection::Out => "out",
436 crate::graph::query::TraversalDirection::In => "in",
437 crate::graph::query::TraversalDirection::Both => "both",
440 };
441 Some(format!(
442 "**Expansion:** from `{}` via `{}` [{dir}] (depth {})",
443 e.of, e.via_edge, e.depth,
444 ))
445}
446
447pub fn render_list_markdown(result: &ListResult) -> String {
449 let mut lines = Vec::new();
450
451 lines.push("---".to_string());
452 lines.push(format!("_total: {}", result.total));
453 lines.push(format!("_returned: {}", result.returned));
454 lines.push(format!("_offset: {}", result.offset));
455 lines.push(format!("_total_tokens: {}", result.total_tokens));
456 lines.push("---".to_string());
457 lines.push(String::new());
458
459 if !result.warnings.is_empty() {
460 lines.push("## Filter warnings".to_string());
461 for w in &result.warnings {
462 lines.push(format!("- **{}**: {}", w.code(), w.message()));
463 }
464 lines.push(String::new());
465 }
466
467 for hit in &result.hits {
468 let meta = hit
469 .sections
470 .get("level")
471 .map(|l| format!("{l}, "))
472 .unwrap_or_default();
473 lines.push(format!(
474 "### {} — {} ({meta}_tokens: {})",
475 hit.id, hit.title, hit.tokens,
476 ));
477 lines.push(hit_summary_line(hit));
478 lines.push(String::new());
479 }
480
481 lines.join("\n")
482}
483
484pub fn render_community_context_section(result: &ContextResult, cluster_id: &str) -> String {
492 let mut lines = Vec::new();
493 lines.push(String::new());
494 lines.push("## Community Context".to_string());
495 lines.push(String::new());
496 lines.push(format!("**Cluster {cluster_id}**"));
497 lines.push(String::new());
498
499 if !result.neighbors.is_empty() {
500 lines.push("### Neighbors".to_string());
501 for n in &result.neighbors {
502 let dir = match n.direction {
503 Direction::Outgoing => "→",
504 Direction::Incoming => "←",
505 };
506 lines.push(format!(
507 "- {} —{}— **{}** ({})",
508 result.entity_id, dir, n.id, n.relationship,
509 ));
510 }
511 lines.push(String::new());
512 }
513
514 lines.join("\n")
515}
516
517pub fn render_context_markdown(result: &ContextResult, cluster_id: &str) -> String {
519 let mut lines = Vec::new();
520
521 lines.push("---".to_string());
522 lines.push(format!("_cluster_id: {cluster_id}"));
523 lines.push("---".to_string());
524 lines.push(String::new());
525 lines.push(format!("## Cluster {cluster_id}"));
526 lines.push(String::new());
527
528 lines.push("### Neighbors".to_string());
530 for n in &result.neighbors {
531 let dir = match n.direction {
532 Direction::Outgoing => "→",
533 Direction::Incoming => "←",
534 };
535 lines.push(format!(
536 "- {} —{}— **{}** ({})",
537 result.entity_id, dir, n.id, n.relationship,
538 ));
539 }
540 lines.push(String::new());
541
542 lines.join("\n")
543}
544
545pub fn render_overview_markdown(output: &LouvainOutput, store: &Store) -> String {
548 let mut lines = Vec::new();
549
550 let entity_count: usize = output.clusters.values().map(|c| c.entities.len()).sum();
551
552 lines.push("---".to_string());
553 lines.push(format!("_cluster_count: {}", output.count));
554 lines.push(format!("_entity_count: {entity_count}"));
555 let mod_str = if output.modularity == 0.0 {
557 "0".to_string()
558 } else {
559 format!("{:.4}", output.modularity)
560 };
561 lines.push(format!("_modularity: {mod_str}"));
562 lines.push("---".to_string());
563 lines.push(String::new());
564
565 let mut cluster_ids: Vec<&String> = output.clusters.keys().collect();
567 cluster_ids.sort();
568
569 for cluster_id in cluster_ids {
570 let info = &output.clusters[cluster_id];
571 let summary = generate_auto_summary(store, &info.entities);
572
573 lines.push(format!(
574 "## Cluster {cluster_id} ({} entities)",
575 info.entities.len(),
576 ));
577 if !summary.is_empty() {
578 lines.push(summary);
579 }
580 for entity_id in &info.entities {
581 lines.push(format!("- {entity_id}"));
582 }
583 lines.push(String::new());
584 }
585
586 lines.join("\n")
587}
588
589#[derive(Serialize)]
605pub struct SearchHitEnvelope<'a> {
606 #[serde(flatten)]
607 pub hit: &'a SearchHit,
608 pub summary_heading: String,
609 pub summary_value: String,
610}
611
612#[derive(Serialize)]
622pub struct SearchResultEnvelope<'a> {
623 #[serde(rename = "_total")]
624 pub total: usize,
625 #[serde(rename = "_returned")]
626 pub returned: usize,
627 #[serde(rename = "_offset")]
628 pub offset: usize,
629 #[serde(rename = "_total_tokens")]
633 pub total_tokens: usize,
634 pub hits: Vec<SearchHitEnvelope<'a>>,
635 #[serde(skip_serializing_if = "Option::is_none")]
640 pub facets: Option<&'a Facets>,
641 #[serde(skip_serializing_if = "Vec::is_empty")]
642 pub warnings: &'a Vec<crate::ops::WarningHint>,
643}
644
645#[derive(Serialize)]
651pub struct ListResultEnvelope<'a> {
652 #[serde(rename = "_total")]
653 pub total: usize,
654 #[serde(rename = "_returned")]
655 pub returned: usize,
656 #[serde(rename = "_offset")]
657 pub offset: usize,
658 #[serde(rename = "_total_tokens")]
659 pub total_tokens: usize,
660 pub hits: Vec<SearchHitEnvelope<'a>>,
661 #[serde(skip_serializing_if = "Vec::is_empty")]
662 pub warnings: &'a Vec<crate::ops::WarningHint>,
663}
664
665pub fn build_entity_envelope(
699 entity: &Entity,
700 rendered_body_tokens: usize,
701 full_tokens: Option<usize>,
702 sections_filter: Option<&[String]>,
703 schema_anchor: Option<&str>,
704 outgoing_edges: &[crate::store::Edge],
705) -> serde_json::Value {
706 let mut envelope = serde_json::Map::new();
707 envelope.insert(
708 "_hash".to_string(),
709 serde_json::Value::String(entity.content_hash.clone()),
710 );
711 envelope.insert(
712 "id".to_string(),
713 serde_json::Value::String(entity.id.to_string()),
714 );
715 envelope.insert(
716 "mem".to_string(),
717 serde_json::Value::String(entity.mem.clone()),
718 );
719 envelope.insert(
720 "type".to_string(),
721 serde_json::Value::String(entity.entity_type.clone()),
722 );
723 envelope.insert(
728 "title".to_string(),
729 serde_json::Value::String(entity.title.clone()),
730 );
731
732 let mut metadata = serde_json::Map::new();
748 for (key, value) in &entity.metadata {
749 if key.starts_with('_')
750 || crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str())
751 {
752 continue;
753 }
754 metadata.insert(
755 key.clone(),
756 serde_json::Value::String(value.to_frontmatter_string()),
757 );
758 }
759 envelope.insert("metadata".to_string(), serde_json::Value::Object(metadata));
760
761 envelope.insert(
762 "_tokens".to_string(),
763 serde_json::Value::Number(serde_json::Number::from(rendered_body_tokens)),
764 );
765 if let Some(t) = full_tokens {
766 envelope.insert(
773 "_tokens_unfiltered_body".to_string(),
774 serde_json::Value::Number(serde_json::Number::from(t)),
775 );
776 }
777 if let Some(s) = schema_anchor {
778 envelope.insert(
779 "_mem_schema".to_string(),
780 serde_json::Value::String(s.to_string()),
781 );
782 }
783
784 if let Some(kind) = &entity.stub_kind {
785 envelope.insert(
786 "_stub_kind".to_string(),
787 serde_json::to_value(kind).unwrap_or(serde_json::Value::Null),
788 );
789 }
790
791 let mut sections = serde_json::Map::new();
792 for (key, content) in &entity.sections {
793 if let Some(filter) = sections_filter
794 && !filter.iter().any(|f| f == key)
795 {
796 continue;
797 }
798 sections.insert(key.clone(), serde_json::Value::String(content.clone()));
799 }
800 envelope.insert("sections".to_string(), serde_json::Value::Object(sections));
801
802 let resolve_source = |rel: &crate::entity::Relationship| -> &'static str {
813 outgoing_edges
814 .iter()
815 .find(|e| e.rel_type == rel.rel_type && e.target == rel.target)
816 .map(|e| match e.source {
817 crate::store::EdgeSource::BodyLink => "body_link",
818 crate::store::EdgeSource::Hierarchy => "hierarchy",
819 crate::store::EdgeSource::Explicit => "explicit",
820 })
821 .unwrap_or("explicit")
822 };
823 let relationships = entity
824 .relationships
825 .iter()
826 .map(|rel| {
827 let mut obj = serde_json::Map::new();
828 obj.insert(
829 "rel_type".to_string(),
830 serde_json::Value::String(rel.rel_type.clone()),
831 );
832 obj.insert(
833 "target".to_string(),
834 serde_json::Value::String(rel.target.to_string()),
835 );
836 obj.insert(
837 "source".to_string(),
838 serde_json::Value::String(resolve_source(rel).to_string()),
839 );
840 if let Some(desc) = rel
841 .description
842 .as_deref()
843 .map(str::trim)
844 .filter(|s| !s.is_empty())
845 {
846 obj.insert(
847 "description".to_string(),
848 serde_json::Value::String(desc.to_string()),
849 );
850 }
851 serde_json::Value::Object(obj)
852 })
853 .collect();
854 envelope.insert(
855 "relationships".to_string(),
856 serde_json::Value::Array(relationships),
857 );
858
859 serde_json::Value::Object(envelope)
860}
861
862pub fn build_search_envelope<'a>(
864 result: &'a SearchResult,
865 offset: usize,
866) -> SearchResultEnvelope<'a> {
867 SearchResultEnvelope {
868 total: result.total,
869 returned: result.returned,
870 offset,
871 total_tokens: result.total_tokens,
872 hits: result.hits.iter().map(build_hit_envelope).collect(),
873 facets: result.facets.as_ref(),
874 warnings: &result.warnings,
875 }
876}
877
878pub fn build_list_envelope(result: &ListResult) -> ListResultEnvelope<'_> {
880 ListResultEnvelope {
881 total: result.total,
882 returned: result.returned,
883 offset: result.offset,
884 total_tokens: result.total_tokens,
885 hits: result.hits.iter().map(build_hit_envelope).collect(),
886 warnings: &result.warnings,
887 }
888}
889
890fn build_hit_envelope(hit: &SearchHit) -> SearchHitEnvelope<'_> {
891 let (heading, value) = hit_summary_pair(hit);
892 SearchHitEnvelope {
893 hit,
894 summary_heading: heading,
895 summary_value: value,
896 }
897}
898
899fn hit_summary_line(hit: &SearchHit) -> String {
909 let (heading, value) = hit_summary_pair(hit);
910 format!("**{heading}**: {value}")
911}
912
913fn hit_summary_pair(hit: &SearchHit) -> (String, String) {
923 if let Some(summary) = &hit.summary {
924 return (summary.heading.clone(), summary.value.clone());
925 }
926 summary_pair(type_by_name(&hit.entity_type).as_deref(), &hit.sections)
927}
928
929fn summary_pair(
931 schema: Option<&TypeDefinition>,
932 sections: &HashMap<String, String>,
933) -> (String, String) {
934 match schema {
935 Some(schema) => lead_section_pair(schema, |k| sections.get(k).map(String::as_str)),
936 None => ("Summary".to_string(), "—".to_string()),
937 }
938}
939
940pub(crate) fn lead_section_pair<'a>(
948 schema: &TypeDefinition,
949 get_section: impl Fn(&str) -> Option<&'a str>,
950) -> (String, String) {
951 let Some(section) = schema
952 .required_sections()
953 .next()
954 .or(schema.sections.first())
955 else {
956 return ("Summary".to_string(), "—".to_string());
957 };
958 let value = get_section(section.key.as_str()).unwrap_or("—");
959 (section.heading.clone(), value.to_string())
960}
961
962fn section_key_to_heading(key: &str) -> String {
966 let mut chars = key.chars();
967 match chars.next() {
968 None => String::new(),
969 Some(c) => {
970 let first: String = c.to_uppercase().collect();
971 let rest: String = chars.map(|c| if c == '_' { ' ' } else { c }).collect();
972 format!("{first}{rest}")
973 }
974 }
975}
976
977fn section_heading_for(type_def: Option<&TypeDefinition>, key: &str) -> String {
984 type_def
985 .and_then(|t| t.sections.iter().find(|s| s.key == key))
986 .map(|s| s.heading.clone())
987 .unwrap_or_else(|| section_key_to_heading(key))
988}
989
990fn lookup_builtin_type(name: &str) -> Option<Arc<TypeDefinition>> {
999 static CACHE: OnceLock<Vec<Arc<Schema>>> = OnceLock::new();
1000 let schemas =
1001 CACHE.get_or_init(|| memstead_schema::builtins::load_builtin_schemas().unwrap_or_default());
1002 for s in schemas {
1003 if let Some(t) = s.get_type(name) {
1004 return Some(t);
1005 }
1006 }
1007 None
1008}
1009
1010pub fn render_type_catalog_markdown() -> String {
1016 render_type_catalog_lines(all_types())
1017}
1018
1019pub fn render_type_catalog_markdown_for(schema: &Schema) -> String {
1025 let mut types: Vec<Arc<TypeDefinition>> = schema.types.values().cloned().collect();
1026 types.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
1027 render_type_catalog_lines(types)
1028}
1029
1030fn render_type_catalog_lines(types: Vec<Arc<TypeDefinition>>) -> String {
1031 let mut lines = vec![
1032 "# Available types".to_string(),
1033 String::new(),
1034 "Run `memstead type <name>` to see its metadata fields, sections, relationship types, and writing guidance — over MCP, `memstead_schema` takes the *schema* name and returns every type at once."
1035 .to_string(),
1036 String::new(),
1037 ];
1038 for schema in types {
1039 let required_sections = schema.required_sections().count();
1040 let total_sections = schema.sections.len();
1041 let metadata_count = schema.metadata_fields.len();
1042 lines.push(format!(
1043 "- **{}** — {} sections ({} required), {} metadata fields, staleness {}d",
1044 schema.name.as_str(),
1045 total_sections,
1046 required_sections,
1047 metadata_count,
1048 schema.staleness_threshold_days,
1049 ));
1050 }
1051 lines.push(String::new());
1052 lines.join("\n")
1053}
1054
1055pub fn render_type_info_markdown(schema: &TypeDefinition) -> String {
1057 let mut lines = Vec::new();
1058 lines.push(format!("# Type: {}", schema.name.as_str()));
1059 lines.push(String::new());
1060 lines.push(format!(
1061 "Staleness threshold: {} days. Hierarchy: `{}`.",
1062 schema.staleness_threshold_days, schema.hierarchy_relationship,
1063 ));
1064 lines.push(String::new());
1065
1066 lines.push("## Metadata fields".to_string());
1068 for field in &schema.metadata_fields {
1069 lines.push(format!("- {}", describe_metadata_field(field)));
1070 }
1071 lines.push(String::new());
1072
1073 lines.push("## Sections".to_string());
1075 for section in &schema.sections {
1076 let req = if section.required {
1077 "required"
1078 } else {
1079 "optional"
1080 };
1081 let catch_all = if section.catch_all { ", catch-all" } else { "" };
1082 lines.push(format!(
1083 "- **{}** ({req}{catch_all}, search_weight: {:.1})",
1084 section.key, section.search_weight,
1085 ));
1086 for rule in §ion.write_rules {
1087 lines.push(format!(" - Write rule: {rule}"));
1088 }
1089 }
1090 lines.push(String::new());
1091
1092 lines.push("## Relationship types (with edge weights)".to_string());
1094 for (rel_type, weight) in &schema.edge_weights {
1095 if rel_type == "_default" {
1096 continue;
1097 }
1098 let mut flags: Vec<&str> = Vec::new();
1099 if rel_type == &schema.hierarchy_relationship {
1100 flags.push("hierarchy");
1101 }
1102 if schema
1103 .no_self_loop_relationships
1104 .iter()
1105 .any(|r| r == rel_type)
1106 {
1107 flags.push("no-self-loop");
1108 }
1109 let flag_str = if flags.is_empty() {
1110 String::new()
1111 } else {
1112 format!(" ({})", flags.join(", "))
1113 };
1114 lines.push(format!("- **{rel_type}**: {weight}{flag_str}"));
1115 }
1116 if let Some((_, default_weight)) = schema.edge_weights.iter().find(|(n, _)| *n == "_default") {
1118 lines.push(format!(
1119 "- _default_ (any other relationship type): {default_weight}"
1120 ));
1121 }
1122 lines.push(String::new());
1123
1124 if !schema.write_rules.is_empty() {
1126 lines.push("## Writing guidance".to_string());
1127 for rule in &schema.write_rules {
1128 lines.push(format!("- {rule}"));
1129 }
1130 lines.push(String::new());
1131 }
1132
1133 let system_msg = schema.system_message_str();
1135 if !system_msg.is_empty() {
1136 lines.push("## System context".to_string());
1137 lines.push(system_msg.to_string());
1138 lines.push(String::new());
1139 }
1140
1141 if let Some(ex) = &schema.exemplar {
1145 lines.push("## Exemplar (engine-validated)".to_string());
1146 lines.push(String::new());
1147 lines.push(format!("Title: {}", ex.title));
1148 if !ex.metadata.is_empty() {
1149 lines.push("Metadata:".to_string());
1150 for (k, v) in &ex.metadata {
1151 lines.push(format!("- {k}: {v}"));
1152 }
1153 }
1154 for (key, body) in &ex.sections {
1155 let heading = schema
1156 .section(key)
1157 .map(|s| s.heading.clone())
1158 .unwrap_or_else(|| key.clone());
1159 lines.push(format!("### {heading}"));
1160 lines.push(body.clone());
1161 }
1162 if !ex.relations.is_empty() {
1163 lines.push("Relations (placeholder targets):".to_string());
1164 for r in &ex.relations {
1165 match &r.description {
1166 Some(d) => lines.push(format!("- {} → {} — {d}", r.rel_type, r.to)),
1167 None => lines.push(format!("- {} → {}", r.rel_type, r.to)),
1168 }
1169 }
1170 }
1171 lines.push(String::new());
1172 }
1173
1174 lines.join("\n")
1175}
1176
1177pub fn per_edge_description_str(p: PerEdgeDescription) -> &'static str {
1183 match p {
1184 PerEdgeDescription::Forbidden => "forbidden",
1185 PerEdgeDescription::Optional => "optional",
1186 PerEdgeDescription::Required => "required",
1187 }
1188}
1189
1190pub fn manual_authoring_str(p: ManualAuthoring) -> &'static str {
1192 match p {
1193 ManualAuthoring::Allow => "allow",
1194 ManualAuthoring::Warn => "warn",
1195 ManualAuthoring::Forbidden => "forbidden",
1196 }
1197}
1198
1199#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1215pub enum SchemaVerbosity {
1216 #[default]
1217 Full,
1218 Lite,
1219}
1220
1221impl SchemaVerbosity {
1222 pub fn from_wire(s: &str) -> Option<Self> {
1227 match s {
1228 "full" => Some(Self::Full),
1229 "lite" => Some(Self::Lite),
1230 _ => None,
1231 }
1232 }
1233
1234 pub fn as_wire(self) -> &'static str {
1236 match self {
1237 Self::Full => "full",
1238 Self::Lite => "lite",
1239 }
1240 }
1241}
1242
1243#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1270pub enum OriginClass {
1271 FirstParty,
1273 #[default]
1276 ThirdParty,
1277}
1278
1279impl OriginClass {
1280 pub fn as_wire(self) -> &'static str {
1284 match self {
1285 Self::FirstParty => "first-party",
1286 Self::ThirdParty => "third-party",
1287 }
1288 }
1289
1290 pub fn is_third_party(self) -> bool {
1293 matches!(self, Self::ThirdParty)
1294 }
1295}
1296
1297fn append_section_format(
1318 obj: &mut serde_json::Map<String, serde_json::Value>,
1319 s: &memstead_schema::SectionDef,
1320) {
1321 if let Some(content) = &s.content {
1322 obj.insert("content".into(), serde_json::json!(content));
1323 obj.insert(
1324 "format_severity".into(),
1325 serde_json::json!(s.format_severity),
1326 );
1327 }
1328 if let Some(pattern) = &s.item_pattern {
1329 obj.insert("item_pattern".into(), serde_json::json!(pattern));
1330 }
1331 if let Some(table) = &s.table {
1332 obj.insert("table".into(), serde_json::json!(table));
1333 }
1334 if let Some(example) = &s.example {
1335 obj.insert("example".into(), serde_json::json!(example));
1336 }
1337}
1338
1339pub fn build_schema_payload(
1340 schema: &Arc<Schema>,
1341 used_by: Vec<String>,
1342 verbosity: SchemaVerbosity,
1343 origin: OriginClass,
1344) -> serde_json::Value {
1345 let manifest = &schema.manifest;
1346 let verbosity = if origin.is_third_party() {
1354 SchemaVerbosity::Lite
1355 } else {
1356 verbosity
1357 };
1358
1359 let relationships: Vec<serde_json::Value> = manifest
1370 .relationships
1371 .definitions
1372 .iter()
1373 .filter(|d| d.name != "_default")
1374 .map(|d| {
1375 let mut o = serde_json::json!({
1396 "name": d.name,
1397 "description": d.description,
1398 "when_to_use": d.when_to_use,
1399 "default_weight": d.default_weight,
1400 "acyclic": d.acyclic,
1401 "per_edge_description": per_edge_description_str(d.per_edge_description),
1402 "manual_authoring": manual_authoring_str(d.manual_authoring),
1403 "allowed_sources": d.source_types,
1404 "allowed_targets": d.target_types,
1405 });
1406 if d.derivation {
1412 o["derivation"] = serde_json::json!(true);
1413 }
1414 o
1415 })
1416 .collect();
1417
1418 let cross_mem_relationships: Vec<serde_json::Value> = manifest
1425 .cross_mem_relationships
1426 .iter()
1427 .map(|entry| {
1428 let definitions: Vec<serde_json::Value> = entry
1429 .definitions
1430 .iter()
1431 .filter(|d| d.name != "_default")
1432 .map(|d| {
1433 serde_json::json!({
1434 "name": d.name,
1435 "description": d.description,
1436 "when_to_use": d.when_to_use,
1437 "default_weight": d.default_weight,
1438 "source_types": d.source_types,
1439 "target_types": d.target_types,
1440 "per_edge_description": per_edge_description_str(d.per_edge_description),
1441 })
1442 })
1443 .collect();
1444 serde_json::json!({
1445 "to_schema": entry.to_schema,
1446 "definitions": definitions,
1447 })
1448 })
1449 .collect();
1450
1451 let types_full: Vec<serde_json::Value> = manifest
1454 .types
1455 .iter()
1456 .filter_map(|name| schema.types.get(name.as_str()).map(|td| (name, td)))
1457 .map(|(_, td)| {
1458 let sections: Vec<serde_json::Value> = td
1459 .sections
1460 .iter()
1461 .map(|s| {
1462 let mut obj = serde_json::json!({
1463 "key": s.key,
1464 "heading": s.heading,
1465 "required": s.required,
1466 "write_rules": s.write_rules,
1467 });
1468 append_section_format(obj.as_object_mut().unwrap(), s);
1474 obj
1475 })
1476 .collect();
1477
1478 let fields: Vec<serde_json::Value> = td
1479 .metadata_fields
1480 .iter()
1481 .map(|f| {
1482 let mut obj = serde_json::json!({
1483 "name": f.key,
1484 "description": f.description,
1485 "required": f.is_required(),
1486 });
1487 if let Some(enum_values) = &f.enum_values {
1488 obj.as_object_mut()
1489 .unwrap()
1490 .insert("enum".into(), serde_json::json!(enum_values));
1491 }
1492 if let Some(default) = &f.default_value {
1499 obj.as_object_mut()
1500 .unwrap()
1501 .insert("default".into(), serde_json::json!(default));
1502 }
1503 obj.as_object_mut().unwrap().insert(
1509 "filterable".into(),
1510 match f.filterable.as_wire_str() {
1511 Some(s) => serde_json::json!(s),
1512 None => serde_json::Value::Null,
1513 },
1514 );
1515 obj
1516 })
1517 .collect();
1518
1519 let required_outgoing: Vec<serde_json::Value> = td
1534 .required_outgoing
1535 .iter()
1536 .map(|block| {
1537 serde_json::json!({
1538 "relationships": block.relationships,
1539 "cardinality": block.cardinality.to_string(),
1540 "severity": block.severity,
1541 })
1542 })
1543 .collect();
1544
1545 let constraints: Vec<serde_json::Value> = td
1554 .constraints
1555 .iter()
1556 .map(|c| match c {
1557 memstead_schema::ConstraintDef::RequiresWhen {
1558 field,
1559 when_field,
1560 when_value,
1561 severity,
1562 } => serde_json::json!({
1563 "kind": "requires_when",
1564 "field": field,
1565 "when_field": when_field,
1566 "when_value": when_value,
1567 "severity": severity,
1568 }),
1569 memstead_schema::ConstraintDef::Unique { fields, severity } => {
1570 serde_json::json!({
1571 "kind": "unique",
1572 "fields": fields,
1573 "severity": severity,
1574 })
1575 }
1576 memstead_schema::ConstraintDef::EnumFromNeighbour {
1577 field,
1578 rel_type,
1579 section,
1580 severity,
1581 } => serde_json::json!({
1582 "kind": "enum_from_neighbour",
1583 "field": field,
1584 "rel_type": rel_type,
1585 "section": section,
1586 "severity": severity,
1587 }),
1588 memstead_schema::ConstraintDef::StatusPropagation {
1589 field,
1590 value,
1591 rel_type,
1592 direction,
1593 severity,
1594 } => serde_json::json!({
1595 "kind": "status_propagation",
1596 "field": field,
1597 "value": value,
1598 "rel_type": rel_type,
1599 "direction": direction,
1600 "severity": severity,
1601 }),
1602 })
1603 .collect();
1604 let mut obj = serde_json::json!({
1605 "name": td.name,
1606 "description": td.description,
1607 "when_to_use": td.when_to_use,
1608 "sections": sections,
1609 "fields": fields,
1610 "writing_guidance": td.write_rules,
1611 "system_context": td.system_message_str(),
1612 "staleness_threshold_days": td.staleness_threshold_days,
1613 "no_self_loop_relationships": td.no_self_loop_relationships,
1614 "required_outgoing": required_outgoing,
1615 "constraints": constraints,
1616 });
1617 if td.leaf {
1621 obj["leaf"] = serde_json::json!(true);
1622 }
1623 if let Some(ex) = &td.exemplar {
1630 let relations: Vec<serde_json::Value> = ex
1631 .relations
1632 .iter()
1633 .map(|r| {
1634 let mut o = serde_json::json!({
1635 "to": r.to,
1636 "type": r.rel_type,
1637 });
1638 if let Some(d) = &r.description {
1639 o["description"] = serde_json::json!(d);
1640 }
1641 o
1642 })
1643 .collect();
1644 obj["exemplar"] = serde_json::json!({
1645 "title": ex.title,
1646 "metadata": ex.metadata,
1647 "sections": ex.sections,
1648 "relations": relations,
1649 });
1650 }
1651 obj
1652 })
1653 .collect();
1654
1655 let mode = match manifest.relationships.mode {
1656 RelationshipMode::Strict => "strict",
1657 RelationshipMode::Open => "open",
1658 };
1659
1660 let full = verbosity == SchemaVerbosity::Full;
1661
1662 let mut payload = serde_json::json!({
1666 "ref": format!("{}@{}", manifest.name, schema.version),
1667 "relationship_mode": mode,
1668 "community": {
1669 "resolution": manifest.community.resolution,
1670 "seed": manifest.community.seed,
1671 },
1672 "used_by": used_by,
1673 "origin": origin.as_wire(),
1679 });
1680 let obj = payload.as_object_mut().unwrap();
1681
1682 if full {
1687 obj.insert(
1688 "description".into(),
1689 serde_json::Value::String(manifest.description.clone()),
1690 );
1691 obj.insert(
1692 "when_to_use".into(),
1693 serde_json::Value::String(manifest.when_to_use.clone()),
1694 );
1695 if let Some(msg) = &manifest.system_message {
1701 obj.insert(
1702 "system_context".into(),
1703 serde_json::Value::String(msg.clone()),
1704 );
1705 }
1706 }
1707
1708 obj.insert(
1715 "no_self_loop_relationships_effect".into(),
1716 serde_json::Value::String(
1717 "Per-type `no_self_loop_relationships` governs exactly one behaviour: \
1718 memstead_relate refuses a self-loop (from == to) on a rel-type the \
1719 source type lists here. It does not propagate impact, imply an \
1720 evidence obligation, or have any other effect (the name says it \
1721 all). To declare real impact propagation, use the \
1722 `status_propagation` constraint (`constraints:` on the type), which \
1723 taints dependents of a terminal status value via a named rel-type \
1724 and direction and surfaces them as health findings."
1725 .to_string(),
1726 ),
1727 );
1728
1729 if let Some(target) = &manifest.alias_target_rel_type {
1738 obj.insert(
1739 "alias_target_rel_type".into(),
1740 serde_json::Value::String(target.clone()),
1741 );
1742 }
1743
1744 if full && let Some(dwg) = &manifest.default_writing_guidance {
1751 let mut block = serde_json::Map::new();
1752 if let Some(avoid) = &dwg.avoid {
1753 block.insert("avoid".into(), serde_json::Value::String(avoid.clone()));
1754 }
1755 if let Some(goal) = &dwg.goal {
1756 block.insert("goal".into(), serde_json::Value::String(goal.clone()));
1757 }
1758 if !block.is_empty() {
1759 obj.insert(
1760 "default_writing_guidance".into(),
1761 serde_json::Value::Object(block),
1762 );
1763 }
1764 }
1765
1766 if full {
1767 obj.insert(
1768 "relationships".into(),
1769 serde_json::Value::Array(relationships),
1770 );
1771 if !cross_mem_relationships.is_empty() {
1775 obj.insert(
1776 "cross_mem_relationships".into(),
1777 serde_json::Value::Array(cross_mem_relationships),
1778 );
1779 }
1780 obj.insert("types".into(), serde_json::Value::Array(types_full));
1781 } else {
1782 let relationships_summary: Vec<serde_json::Value> = relationships
1792 .iter()
1793 .map(|r| {
1794 let mut o = serde_json::json!({
1795 "name": r["name"],
1796 "allowed_sources": r["allowed_sources"],
1797 "allowed_targets": r["allowed_targets"],
1798 "manual_authoring": r["manual_authoring"],
1799 "acyclic": r["acyclic"],
1800 "per_edge_description": r["per_edge_description"],
1801 });
1802 if r.get("derivation") == Some(&serde_json::json!(true)) {
1803 o["derivation"] = serde_json::json!(true);
1804 }
1805 o
1806 })
1807 .collect();
1808 obj.insert(
1809 "relationships_summary".into(),
1810 serde_json::Value::Array(relationships_summary),
1811 );
1812
1813 if !cross_mem_relationships.is_empty() {
1817 let cross_summary: Vec<serde_json::Value> = cross_mem_relationships
1818 .iter()
1819 .map(|e| {
1820 let definitions: Vec<serde_json::Value> = e["definitions"]
1821 .as_array()
1822 .map(|defs| {
1823 defs.iter()
1824 .map(|d| {
1825 serde_json::json!({
1826 "name": d["name"],
1827 "source_types": d["source_types"],
1828 "target_types": d["target_types"],
1829 })
1830 })
1831 .collect()
1832 })
1833 .unwrap_or_default();
1834 serde_json::json!({
1835 "to_schema": e["to_schema"],
1836 "definitions": definitions,
1837 })
1838 })
1839 .collect();
1840 obj.insert(
1841 "cross_mem_relationships_summary".into(),
1842 serde_json::Value::Array(cross_summary),
1843 );
1844 }
1845
1846 let types_summary: Vec<serde_json::Value> = types_full
1860 .iter()
1861 .map(|t| {
1862 let sections: Vec<serde_json::Value> = t["sections"]
1863 .as_array()
1864 .map(|secs| {
1865 secs.iter()
1866 .map(|s| {
1867 let mut o = serde_json::Map::new();
1868 o.insert("key".into(), s["key"].clone());
1869 o.insert("required".into(), s["required"].clone());
1870 for k in [
1874 "content",
1875 "item_pattern",
1876 "table",
1877 "example",
1878 "format_severity",
1879 ] {
1880 if let Some(v) = s.get(k) {
1881 o.insert(k.into(), v.clone());
1882 }
1883 }
1884 serde_json::Value::Object(o)
1885 })
1886 .collect()
1887 })
1888 .unwrap_or_default();
1889 let fields: Vec<serde_json::Value> = t["fields"]
1890 .as_array()
1891 .map(|fs| {
1892 fs.iter()
1893 .map(|f| {
1894 let mut o = serde_json::Map::new();
1895 o.insert("name".into(), f["name"].clone());
1896 o.insert("required".into(), f["required"].clone());
1897 if let Some(e) = f.get("enum") {
1898 o.insert("enum".into(), e.clone());
1899 }
1900 if let Some(d) = f.get("default") {
1901 o.insert("default".into(), d.clone());
1902 }
1903 serde_json::Value::Object(o)
1904 })
1905 .collect()
1906 })
1907 .unwrap_or_default();
1908 let mut o = serde_json::json!({
1909 "name": t["name"],
1910 "sections": sections,
1911 "fields": fields,
1912 "no_self_loop_relationships": t["no_self_loop_relationships"],
1913 "required_outgoing": t["required_outgoing"],
1914 "constraints": t["constraints"],
1915 });
1916 if t.get("leaf") == Some(&serde_json::json!(true)) {
1919 o["leaf"] = serde_json::json!(true);
1920 }
1921 o
1922 })
1923 .collect();
1924 obj.insert(
1925 "types_summary".into(),
1926 serde_json::Value::Array(types_summary),
1927 );
1928 }
1929
1930 payload
1931}
1932
1933fn describe_metadata_field(field: &memstead_schema::MetadataFieldDef) -> String {
1935 let type_str = match field.field_type {
1936 FieldType::String => "String",
1937 FieldType::Number => "Number",
1938 FieldType::Date => "Date",
1939 FieldType::Boolean => "Boolean",
1940 };
1941
1942 let mut flags: Vec<&str> = Vec::new();
1943 if !field.is_required() {
1944 flags.push("optional");
1945 } else {
1946 flags.push("required");
1947 }
1948 if field.init_timestamp {
1949 flags.push("auto-init");
1950 }
1951 if field.auto_timestamp {
1952 flags.push("auto-update");
1953 }
1954 match field.serialization {
1955 Serialization::CsvArray => flags.push("csv array"),
1956 Serialization::OmitWhenFalsy => flags.push("omit when falsy"),
1957 Serialization::Default => {}
1958 }
1959
1960 let mut extras: Vec<String> = Vec::new();
1961 if let Some(values) = &field.enum_values {
1962 extras.push(format!("enum: {}", values.join(", ")));
1963 }
1964 if let Some(default) = &field.default_value {
1965 extras.push(format!("default: {default}"));
1966 }
1967 let filterable_str = match field.filterable {
1968 Filterable::None => None,
1969 Filterable::Equality => Some("filterable: equality"),
1970 Filterable::Range => Some("filterable: range"),
1971 };
1972 if let Some(f) = filterable_str {
1973 extras.push(f.to_string());
1974 }
1975
1976 let extras_str = if extras.is_empty() {
1977 String::new()
1978 } else {
1979 format!(" — {}", extras.join(" — "))
1980 };
1981
1982 format!(
1983 "**{key}**: {type_str} ({flags}){extras_str}",
1984 key = field.key,
1985 flags = flags.join(", "),
1986 )
1987}
1988
1989#[cfg(test)]
1990mod tests {
1991 use super::*;
1992 use crate::{Entity, EntityId, ListResult, SearchResult};
1993 use indexmap::IndexMap;
1994 use std::collections::HashMap;
1995
1996 fn make_hit(id: &str, title: &str, entity_type: &str, sections: &[(&str, &str)]) -> SearchHit {
1997 SearchHit {
1998 id: EntityId(id.to_string()),
1999 last_modified: None,
2000 title: title.to_string(),
2001 mem: id.split("--").next().unwrap_or("").to_string(),
2002 entity_type: entity_type.to_string(),
2003 stub: false,
2004 score: 1.0,
2005 tokens: 10,
2006 snippet: None,
2007 sections: sections
2008 .iter()
2009 .map(|(k, v)| (k.to_string(), v.to_string()))
2010 .collect(),
2011 score_breakdown: None,
2012 matched_terms: None,
2013 expansion: None,
2014 summary: None,
2017 }
2018 }
2019
2020 fn search_result(hits: Vec<SearchHit>) -> SearchResult {
2021 let returned = hits.len();
2022 let total_tokens = hits.iter().map(|h| h.tokens).sum();
2023 SearchResult {
2024 total: returned,
2025 returned,
2026 offset: 0,
2027 total_tokens,
2028 hits,
2029 facets: None,
2030 warnings: vec![],
2031 }
2032 }
2033
2034 fn list_result(hits: Vec<SearchHit>) -> ListResult {
2035 let returned = hits.len();
2036 ListResult {
2037 total: returned,
2038 returned,
2039 offset: 0,
2040 total_tokens: hits.iter().map(|h| h.tokens).sum(),
2041 hits,
2042 warnings: vec![],
2043 }
2044 }
2045
2046 fn test_entity() -> Entity {
2047 Entity {
2048 id: EntityId("specs--test-entity".to_string()),
2049 title: "Test Entity".to_string(),
2050 entity_type: "spec".to_string(),
2051 mem: "specs".to_string(),
2052 file_path: "test-entity.md".to_string(),
2053 metadata: IndexMap::new(),
2054 sections: IndexMap::from([
2055 ("identity".to_string(), "A test entity for unit tests.".to_string()),
2056 ("purpose".to_string(), "Validates render logic.".to_string()),
2057 ("specifies".to_string(), "Long section content that adds significant token weight to the full entity estimate.".to_string()),
2058 ]),
2059 relationships: vec![],
2060 content_hash: "abc123".to_string(),
2061 stub: false,
2062 stub_kind: None,
2063 heading_spans: std::collections::HashMap::new(),
2064 raw_section_headings: Vec::new(),
2065 }
2066 }
2067
2068 #[test]
2069 fn section_key_to_heading_basic() {
2070 assert_eq!(section_key_to_heading("identity"), "Identity");
2071 assert_eq!(section_key_to_heading("current_state"), "Current state");
2072 }
2073
2074 #[test]
2075 fn render_uses_schema_declared_heading_for_non_trivial_casing() {
2076 let mut sections: IndexMap<String, String> = IndexMap::new();
2082 sections.insert("claim_a".to_string(), "Body A.".to_string());
2083 sections.insert("claim_b".to_string(), "Body B.".to_string());
2084
2085 let entity = Entity {
2086 id: EntityId("ingest--example".to_string()),
2087 title: "Example".to_string(),
2088 entity_type: "inconsistency".to_string(),
2089 mem: "ingest".to_string(),
2090 file_path: "example.md".to_string(),
2091 metadata: IndexMap::new(),
2092 sections,
2093 relationships: vec![],
2094 content_hash: "h".to_string(),
2095 stub: false,
2096 stub_kind: None,
2097 heading_spans: std::collections::HashMap::new(),
2098 raw_section_headings: Vec::new(),
2099 };
2100
2101 let md = render_entity_markdown(&entity, None);
2102 assert!(
2103 md.contains("## Claim A"),
2104 "expected schema-declared `## Claim A` heading; got:\n{md}"
2105 );
2106 assert!(
2107 md.contains("## Claim B"),
2108 "expected schema-declared `## Claim B` heading; got:\n{md}"
2109 );
2110 assert!(
2112 !md.contains("## Claim a"),
2113 "renderer must not fall back to key-derivation when the \
2114 schema declares a heading; got:\n{md}"
2115 );
2116 }
2117
2118 #[test]
2119 fn render_falls_back_to_key_derivation_for_unknown_types() {
2120 let mut sections: IndexMap<String, String> = IndexMap::new();
2124 sections.insert("identity".to_string(), "body".to_string());
2125
2126 let entity = Entity {
2127 id: EntityId("custom--example".to_string()),
2128 title: "Example".to_string(),
2129 entity_type: "not-a-builtin-type".to_string(),
2130 mem: "custom".to_string(),
2131 file_path: "example.md".to_string(),
2132 metadata: IndexMap::new(),
2133 sections,
2134 relationships: vec![],
2135 content_hash: "h".to_string(),
2136 stub: false,
2137 stub_kind: None,
2138 heading_spans: std::collections::HashMap::new(),
2139 raw_section_headings: Vec::new(),
2140 };
2141
2142 let md = render_entity_markdown(&entity, None);
2143 assert!(
2144 md.contains("## Identity"),
2145 "fallback derivation must produce `## Identity`; got:\n{md}"
2146 );
2147 }
2148
2149 #[test]
2156 fn render_entity_sections_follow_indexmap_insertion_order() {
2157 let mut sections: IndexMap<String, String> = IndexMap::new();
2158 sections.insert("specifies".to_string(), "S content.".to_string());
2159 sections.insert("purpose".to_string(), "P content.".to_string());
2160 sections.insert("identity".to_string(), "I content.".to_string());
2161
2162 let entity = Entity {
2163 id: EntityId("specs--order-test".to_string()),
2164 title: "Order Test".to_string(),
2165 entity_type: "spec".to_string(),
2166 mem: "specs".to_string(),
2167 file_path: "order-test.md".to_string(),
2168 metadata: IndexMap::new(),
2169 sections,
2170 relationships: vec![],
2171 content_hash: "abc123".to_string(),
2172 stub: false,
2173 stub_kind: None,
2174 heading_spans: std::collections::HashMap::new(),
2175 raw_section_headings: Vec::new(),
2176 };
2177
2178 let md = render_entity_markdown(&entity, None);
2179 let specifies_pos = md.find("## Specifies").expect("## Specifies must appear");
2180 let purpose_pos = md.find("## Purpose").expect("## Purpose must appear");
2181 let identity_pos = md.find("## Identity").expect("## Identity must appear");
2182
2183 assert!(
2184 specifies_pos < purpose_pos,
2185 "Specifies (inserted first) must render before Purpose; got:\n{md}"
2186 );
2187 assert!(
2188 purpose_pos < identity_pos,
2189 "Purpose (inserted second) must render before Identity; got:\n{md}"
2190 );
2191 }
2192
2193 #[test]
2199 fn tokens_reflect_filtered_output() {
2200 let entity = test_entity();
2201
2202 let full = render_entity_markdown(&entity, None);
2204 assert!(full.contains("_tokens:"), "should have _tokens");
2205 assert!(
2206 !full.contains("_tokens_unfiltered_body:"),
2207 "should NOT have _tokens_unfiltered_body when unfiltered"
2208 );
2209 assert!(
2210 !full.contains("_tokens_full:"),
2211 "old _tokens_full name must not survive — rename is one-way"
2212 );
2213
2214 let filtered = render_entity_markdown(&entity, Some(&["identity".to_string()]));
2216 assert!(filtered.contains("_tokens:"), "should have _tokens");
2217 assert!(
2218 filtered.contains("_tokens_unfiltered_body:"),
2219 "should have _tokens_unfiltered_body when filtered"
2220 );
2221 assert!(
2222 !filtered.contains("_tokens_full:"),
2223 "old _tokens_full name must not survive — rename is one-way"
2224 );
2225
2226 let full_tokens: usize = full
2228 .lines()
2229 .find(|l| l.starts_with("_tokens:"))
2230 .unwrap()
2231 .trim_start_matches("_tokens: ")
2232 .parse()
2233 .unwrap();
2234 let filtered_tokens: usize = filtered
2235 .lines()
2236 .find(|l| l.starts_with("_tokens:"))
2237 .unwrap()
2238 .trim_start_matches("_tokens: ")
2239 .parse()
2240 .unwrap();
2241 let tokens_unfiltered_body: usize = filtered
2242 .lines()
2243 .find(|l| l.starts_with("_tokens_unfiltered_body:"))
2244 .unwrap()
2245 .trim_start_matches("_tokens_unfiltered_body: ")
2246 .parse()
2247 .unwrap();
2248
2249 assert!(
2250 filtered_tokens < full_tokens,
2251 "filtered _tokens ({filtered_tokens}) should be less than full _tokens ({full_tokens})"
2252 );
2253 assert!(
2254 tokens_unfiltered_body >= full_tokens,
2255 "_tokens_unfiltered_body ({tokens_unfiltered_body}) should be >= full render _tokens ({full_tokens})"
2256 );
2257 }
2258
2259 #[test]
2264 fn render_search_uses_first_required_section_for_spec() {
2265 let hit = make_hit(
2266 "specs--demo",
2267 "Demo Spec",
2268 "spec",
2269 &[
2270 ("identity", "A demo spec."),
2271 ("purpose", "Verifies rendering."),
2272 ],
2273 );
2274 let out = render_search_markdown(&search_result(vec![hit]), 0);
2275 assert!(
2276 out.contains("**Identity**: A demo spec."),
2277 "expected Identity line for spec hit, got:\n{out}"
2278 );
2279 }
2280
2281 #[test]
2282 fn render_search_uses_first_required_section_for_memo() {
2283 let hit = make_hit(
2284 "memos--d1",
2285 "Memo One",
2286 "memo",
2287 &[("claim", "Some claim."), ("context", "Some context.")],
2288 );
2289 let out = render_search_markdown(&search_result(vec![hit]), 0);
2290 assert!(
2291 out.contains("**Claim**: Some claim."),
2292 "expected Claim line for memo hit, got:\n{out}"
2293 );
2294 assert!(
2295 !out.contains("**Identity**"),
2296 "memo hit must not render Identity label"
2297 );
2298 assert!(
2299 !out.contains("**Purpose**"),
2300 "memo hit must not render Purpose label"
2301 );
2302 }
2303
2304 #[test]
2305 fn render_search_uses_first_required_section_for_concept() {
2306 let hit = make_hit(
2307 "concepts--thing",
2308 "Thing",
2309 "concept",
2310 &[("definition", "A thing."), ("explanation", "Details.")],
2311 );
2312 let out = render_search_markdown(&search_result(vec![hit]), 0);
2313 assert!(
2314 out.contains("**Definition**: A thing."),
2315 "expected Definition line for concept hit, got:\n{out}"
2316 );
2317 }
2318
2319 #[test]
2320 fn render_search_missing_summary_section_shows_dash() {
2321 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2323 let out = render_search_markdown(&search_result(vec![hit]), 0);
2324 assert!(
2325 out.contains("**Claim**: —"),
2326 "expected Claim dash fallback, got:\n{out}"
2327 );
2328 }
2329
2330 #[test]
2331 fn render_search_mixes_schemas_in_one_result() {
2332 let spec_hit = make_hit(
2333 "specs--s1",
2334 "Spec One",
2335 "spec",
2336 &[("identity", "Spec body.")],
2337 );
2338 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2339 let out = render_search_markdown(&search_result(vec![spec_hit, memo_hit]), 0);
2340 assert!(
2341 out.contains("**Identity**: Spec body."),
2342 "spec hit should still render Identity, got:\n{out}"
2343 );
2344 assert!(
2345 out.contains("**Claim**: Memo claim."),
2346 "memo hit should render Claim in the same output, got:\n{out}"
2347 );
2348 }
2349
2350 #[test]
2351 fn render_search_unknown_schema_shows_summary_dash() {
2352 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2353 let out = render_search_markdown(&search_result(vec![hit]), 0);
2354 assert!(
2355 out.contains("**Summary**: —"),
2356 "unknown schema should render Summary dash, got:\n{out}"
2357 );
2358 }
2359
2360 #[test]
2361 fn summary_pair_falls_back_when_schema_has_no_required_sections() {
2362 use memstead_schema::{SectionDef, TypeDefinition};
2363
2364 let schema = TypeDefinition {
2365 name: "spec".to_string(),
2366 description: "test".to_string(),
2367 when_to_use: "test".to_string(),
2368 boundaries: vec![],
2369 exemplar: None,
2370 legacy_examples: None,
2371 system_message: None,
2372 sections: vec![SectionDef {
2373 key: "note".to_string(),
2374 heading: "Note".to_string(),
2375 required: false,
2376 search_weight: 1.0,
2377 catch_all: false,
2378 write_rules: vec![],
2379 description: None,
2380 content: None,
2381 item_pattern: None,
2382 table: None,
2383 example: None,
2384 format_severity: memstead_schema::ConstraintSeverity::Block,
2385 compiled_content: None,
2386 format_problems: Vec::new(),
2387 }],
2388 metadata_fields: vec![],
2389 title_weight: 1.0,
2390 text_fields: vec![],
2391 hierarchy_relationship: "PART_OF".to_string(),
2392 edge_weight_overrides: indexmap::IndexMap::new(),
2393 edge_weights: indexmap::IndexMap::new(),
2394 no_self_loop_relationships: vec![],
2395 legacy_propagating_relationships: None,
2396 due: None,
2397 leaf: false,
2398 updatable_fields: vec![],
2399 health_required_fields: vec![],
2400 staleness_threshold_days: 90,
2401 write_rules: vec![],
2402 required_outgoing: vec![],
2403 constraints: vec![],
2404 declared_metadata_keys: vec![],
2405 };
2406
2407 let mut sections = HashMap::new();
2408 sections.insert("note".to_string(), "a note".to_string());
2409 assert_eq!(
2410 summary_pair(Some(&schema), §ions),
2411 ("Note".to_string(), "a note".to_string()),
2412 );
2413
2414 assert_eq!(
2415 summary_pair(Some(&schema), &HashMap::new()),
2416 ("Note".to_string(), "—".to_string()),
2417 );
2418 }
2419
2420 #[test]
2425 fn render_list_uses_first_required_section_for_spec() {
2426 let hit = make_hit(
2427 "specs--demo",
2428 "Demo Spec",
2429 "spec",
2430 &[
2431 ("identity", "A demo spec."),
2432 ("purpose", "Verifies rendering."),
2433 ],
2434 );
2435 let out = render_list_markdown(&list_result(vec![hit]));
2436 assert!(
2437 out.contains("**Identity**: A demo spec."),
2438 "expected Identity line for spec hit, got:\n{out}"
2439 );
2440 }
2441
2442 #[test]
2443 fn render_list_uses_first_required_section_for_memo() {
2444 let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
2445 let out = render_list_markdown(&list_result(vec![hit]));
2446 assert!(
2447 out.contains("**Claim**: Some claim."),
2448 "expected Claim line for memo hit, got:\n{out}"
2449 );
2450 assert!(
2451 !out.contains("**Identity**"),
2452 "memo hit must not render Identity label in list output"
2453 );
2454 }
2455
2456 #[test]
2457 fn render_list_uses_first_required_section_for_concept() {
2458 let hit = make_hit(
2459 "concepts--thing",
2460 "Thing",
2461 "concept",
2462 &[("definition", "A thing.")],
2463 );
2464 let out = render_list_markdown(&list_result(vec![hit]));
2465 assert!(
2466 out.contains("**Definition**: A thing."),
2467 "expected Definition line for concept hit, got:\n{out}"
2468 );
2469 }
2470
2471 #[test]
2472 fn render_list_missing_summary_section_shows_dash() {
2473 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2474 let out = render_list_markdown(&list_result(vec![hit]));
2475 assert!(
2476 out.contains("**Claim**: —"),
2477 "expected Claim dash fallback in list output, got:\n{out}"
2478 );
2479 }
2480
2481 #[test]
2482 fn render_list_mixes_schemas_in_one_result() {
2483 let spec_hit = make_hit(
2484 "specs--s1",
2485 "Spec One",
2486 "spec",
2487 &[("identity", "Spec body.")],
2488 );
2489 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2490 let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
2491 assert!(
2492 out.contains("**Identity**: Spec body."),
2493 "spec hit should still render Identity in list output, got:\n{out}"
2494 );
2495 assert!(
2496 out.contains("**Claim**: Memo claim."),
2497 "memo hit should render Claim in list output, got:\n{out}"
2498 );
2499 }
2500
2501 #[test]
2502 fn render_list_unknown_schema_shows_summary_dash() {
2503 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2504 let out = render_list_markdown(&list_result(vec![hit]));
2505 assert!(
2506 out.contains("**Summary**: —"),
2507 "unknown schema should render Summary dash in list output, got:\n{out}"
2508 );
2509 }
2510
2511 #[test]
2516 fn summary_pair_for_spec_returns_identity() {
2517 let schema = type_by_name("spec");
2518 let mut sections = HashMap::new();
2519 sections.insert("identity".to_string(), "A demo spec.".to_string());
2520 assert_eq!(
2521 summary_pair(schema.as_deref(), §ions),
2522 ("Identity".to_string(), "A demo spec.".to_string()),
2523 );
2524 }
2525
2526 #[test]
2527 fn summary_pair_for_memo_returns_claim() {
2528 let schema = type_by_name("memo");
2529 let mut sections = HashMap::new();
2530 sections.insert("claim".to_string(), "Memos matter.".to_string());
2531 assert_eq!(
2532 summary_pair(schema.as_deref(), §ions),
2533 ("Claim".to_string(), "Memos matter.".to_string()),
2534 );
2535 }
2536
2537 #[test]
2538 fn summary_pair_missing_section_returns_dash() {
2539 let schema = type_by_name("memo");
2540 assert_eq!(
2541 summary_pair(schema.as_deref(), &HashMap::new()),
2542 ("Claim".to_string(), "—".to_string()),
2543 );
2544 }
2545
2546 #[test]
2547 fn summary_pair_unknown_schema_returns_summary_dash() {
2548 assert_eq!(
2549 summary_pair(None, &HashMap::new()),
2550 ("Summary".to_string(), "—".to_string()),
2551 );
2552 }
2553
2554 #[test]
2559 fn envelope_serializes_summary_fields() {
2560 let hit = make_hit(
2561 "memos--d1",
2562 "Memo One",
2563 "memo",
2564 &[("claim", "Memos matter.")],
2565 );
2566 let result = search_result(vec![hit]);
2567 let envelope = build_search_envelope(&result, 0);
2568 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2569
2570 assert_eq!(value["_total"], 1);
2574 assert_eq!(value["_returned"], 1);
2575 assert_eq!(value["_offset"], 0);
2576 assert!(
2578 value.get("warnings").is_none(),
2579 "empty warnings must be elided, got: {value}"
2580 );
2581
2582 let hit0 = &value["hits"][0];
2583 assert_eq!(hit0["summary_heading"], "Claim");
2584 assert_eq!(hit0["summary_value"], "Memos matter.");
2585 assert_eq!(hit0["id"], "memos--d1");
2587 assert_eq!(hit0["title"], "Memo One");
2588 assert_eq!(hit0["entity_type"], "memo");
2589 assert_eq!(hit0["mem"], "memos");
2590 assert_eq!(hit0["stub"], false);
2591 assert_eq!(hit0["tokens"], 10);
2592 assert!(hit0["sections"].is_object());
2593 }
2594
2595 #[test]
2596 fn envelope_roundtrips_through_structured_content() {
2597 let spec_hit = make_hit(
2600 "specs--s1",
2601 "Spec One",
2602 "spec",
2603 &[("identity", "Spec body.")],
2604 );
2605 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2606 let result = search_result(vec![spec_hit, memo_hit]);
2607 let envelope = build_search_envelope(&result, 0);
2608 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2609
2610 let hits = value["hits"].as_array().expect("hits must be array");
2611 assert_eq!(hits.len(), 2);
2612 assert_eq!(hits[0]["summary_heading"], "Identity");
2613 assert_eq!(hits[0]["summary_value"], "Spec body.");
2614 assert_eq!(hits[1]["summary_heading"], "Claim");
2615 assert_eq!(hits[1]["summary_value"], "Memo claim.");
2616 }
2617
2618 #[test]
2619 fn list_envelope_includes_total_tokens() {
2620 let hit = make_hit(
2621 "concepts--c1",
2622 "Thing",
2623 "concept",
2624 &[("definition", "A thing.")],
2625 );
2626 let result = list_result(vec![hit]);
2627 let envelope = build_list_envelope(&result);
2628 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2629
2630 assert_eq!(value["_total"], 1);
2632 assert_eq!(value["_total_tokens"], 10);
2633 assert!(value.get("total").is_none(), "unprefixed keys retired");
2634 assert_eq!(value["hits"][0]["summary_heading"], "Definition");
2635 assert_eq!(value["hits"][0]["summary_value"], "A thing.");
2636 }
2637
2638 #[test]
2639 fn envelope_emits_warnings_when_present() {
2640 let mut result = search_result(vec![]);
2641 result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
2644 field: "foo".to_string(),
2645 }];
2646 let envelope = build_search_envelope(&result, 0);
2647 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2648 assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
2649 assert_eq!(value["warnings"][0]["details"]["field"], "foo");
2650 assert!(
2651 value["warnings"][0]["message"]
2652 .as_str()
2653 .is_some_and(|m| m.contains("not filterable"))
2654 );
2655 }
2656
2657 fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
2662 TermMatch {
2663 field: field.to_string(),
2664 snippet: snippet.to_string(),
2665 heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
2666 }
2667 }
2668
2669 fn sample_facets() -> Facets {
2670 use crate::ops::SubsectionFacet;
2671 Facets {
2672 by_type: HashMap::from([
2673 ("spec".to_string(), 7),
2674 ("memo".to_string(), 3),
2675 ("decision".to_string(), 2),
2676 ]),
2677 by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
2678 by_level: HashMap::from([("high".to_string(), 4)]),
2679 by_status: HashMap::from([("active".to_string(), 6)]),
2680 by_confidence: HashMap::from([("medium".to_string(), 3)]),
2681 by_subsection: vec![
2682 SubsectionFacet {
2683 path: vec!["specifies".to_string(), "Response Shapes".to_string()],
2684 count: 4,
2685 },
2686 SubsectionFacet {
2687 path: vec!["purpose".to_string(), "Rationale".to_string()],
2688 count: 2,
2689 },
2690 ],
2691 by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
2692 }
2693 }
2694
2695 #[test]
2696 fn render_search_emits_matched_terms_line() {
2697 let mut hit = make_hit(
2698 "specs--e1",
2699 "Entity One",
2700 "spec",
2701 &[("identity", "Body text.")],
2702 );
2703 hit.matched_terms = Some(HashMap::from([
2704 (
2705 "entity".to_string(),
2706 vec![
2707 tm("title", "...entity...", None),
2708 tm("purpose", "...entity...", None),
2709 tm("purpose", "...entity two...", None),
2710 ],
2711 ),
2712 ("one".to_string(), vec![tm("title", "...one...", None)]),
2713 ]));
2714 let out = render_search_markdown(&search_result(vec![hit]), 0);
2715 assert!(
2716 out.contains("**Matched terms:**"),
2717 "missing Matched terms line; got:\n{out}"
2718 );
2719 assert!(
2720 out.contains("`entity` (purpose×2, title×1)"),
2721 "entity term grouping wrong; got:\n{out}"
2722 );
2723 assert!(
2724 out.contains("`one` (title×1)"),
2725 "one term grouping wrong; got:\n{out}"
2726 );
2727 }
2728
2729 #[test]
2730 fn render_search_emits_score_breakdown_line() {
2731 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2732 hit.score_breakdown = Some(ScoreBreakdown {
2733 bm25: 2.5,
2734 title_boost: 2.0,
2735 field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
2736 expansion_decay: Some(0.5),
2737 });
2738 let out = render_search_markdown(&search_result(vec![hit]), 0);
2739 assert!(
2740 out.contains(
2741 "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
2742 ),
2743 "score breakdown line wrong; got:\n{out}"
2744 );
2745 }
2746
2747 #[test]
2748 fn render_search_omits_expansion_decay_when_none() {
2749 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2750 hit.score_breakdown = Some(ScoreBreakdown {
2751 bm25: 1.5,
2752 title_boost: 1.0,
2753 field_weights: HashMap::new(),
2754 expansion_decay: None,
2755 });
2756 let out = render_search_markdown(&search_result(vec![hit]), 0);
2757 assert!(
2758 out.contains("**Score:** bm25 1.5 + title 1.0"),
2759 "base score wrong; got:\n{out}"
2760 );
2761 assert!(
2762 !out.contains("expansion_decay"),
2763 "expansion_decay must be absent when None; got:\n{out}"
2764 );
2765 }
2766
2767 #[test]
2768 fn render_search_emits_heading_path_line() {
2769 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2770 hit.matched_terms = Some(HashMap::from([(
2771 "x".to_string(),
2772 vec![
2773 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
2774 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
2776 ],
2777 )]));
2778 let out = render_search_markdown(&search_result(vec![hit]), 0);
2779 assert!(
2780 out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
2781 "heading path line wrong; got:\n{out}"
2782 );
2783 }
2784
2785 #[test]
2786 fn render_search_emits_expansion_line() {
2787 let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
2788 hit.expansion = Some(ExpansionInfo {
2789 of: EntityId("specs--seed".to_string()),
2790 via_edge: "refines".to_string(),
2791 via_direction: crate::graph::query::TraversalDirection::Out,
2792 depth: 1,
2793 });
2794 let out = render_search_markdown(&search_result(vec![hit]), 0);
2795 assert!(
2796 out.contains("**Expansion:** from `specs--seed` via `refines` [out] (depth 1)"),
2797 "expansion line reports the traversal direction beside the label; got:\n{out}"
2798 );
2799 }
2800
2801 #[test]
2802 fn render_search_emits_facets_block() {
2803 let mut result = search_result(vec![]);
2804 result.facets = Some(sample_facets());
2805 let out = render_search_markdown(&result, 0);
2806 assert!(
2807 out.contains("## Facets"),
2808 "facets header missing; got:\n{out}"
2809 );
2810 assert!(
2811 out.contains("- **by_type:** spec=7, memo=3, decision=2"),
2812 "by_type bucket wrong; got:\n{out}"
2813 );
2814 assert!(
2815 out.contains("- **by_mem:** specs=10, memos=2"),
2816 "by_mem bucket wrong; got:\n{out}"
2817 );
2818 assert!(
2819 out.contains("- **by_level:** high=4"),
2820 "by_level bucket wrong; got:\n{out}"
2821 );
2822 assert!(
2823 out.contains("- **by_status:** active=6"),
2824 "by_status bucket wrong; got:\n{out}"
2825 );
2826 assert!(
2827 out.contains("- **by_confidence:** medium=3"),
2828 "by_confidence bucket wrong; got:\n{out}"
2829 );
2830 assert!(
2831 out.contains("- **by_expansion:** primary=8, expanded=4"),
2832 "by_expansion bucket wrong; got:\n{out}"
2833 );
2834 assert!(
2835 out.contains("- **by_subsection:**"),
2836 "by_subsection header missing; got:\n{out}"
2837 );
2838 assert!(
2839 out.contains("`specifies › Response Shapes`: 4"),
2840 "subsection facet wrong; got:\n{out}"
2841 );
2842 }
2843
2844 #[test]
2845 fn render_search_omits_facets_block_when_all_empty() {
2846 let mut result = search_result(vec![]);
2847 result.facets = Some(Facets::default());
2848 let out = render_search_markdown(&result, 0);
2849 assert!(
2850 !out.contains("## Facets"),
2851 "empty facets must not emit header; got:\n{out}"
2852 );
2853 }
2854
2855 #[test]
2859 fn search_markdown_covers_every_sidecar_field() {
2860 let mut hit = make_hit(
2861 "specs--e1",
2862 "Entity One",
2863 "spec",
2864 &[("identity", "Body text.")],
2865 );
2866 hit.matched_terms = Some(HashMap::from([(
2867 "entity".to_string(),
2868 vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
2869 )]));
2870 hit.score_breakdown = Some(ScoreBreakdown {
2871 bm25: 1.5,
2872 title_boost: 1.0,
2873 field_weights: HashMap::from([("body".to_string(), 0.4)]),
2874 expansion_decay: Some(0.5),
2875 });
2876 hit.expansion = Some(ExpansionInfo {
2877 of: EntityId("specs--seed".to_string()),
2878 via_edge: "refines".to_string(),
2879 via_direction: crate::graph::query::TraversalDirection::Out,
2880 depth: 2,
2881 });
2882
2883 let mut result = search_result(vec![hit]);
2884 result.facets = Some(sample_facets());
2885
2886 let out = render_search_markdown(&result, 0);
2887 for marker in [
2888 "## Facets",
2889 "- **by_type:**",
2890 "- **by_mem:**",
2891 "- **by_level:**",
2892 "- **by_status:**",
2893 "- **by_confidence:**",
2894 "- **by_expansion:**",
2895 "- **by_subsection:**",
2896 "**Matched terms:**",
2897 "**Score:**",
2898 "**Heading path:**",
2899 "**Expansion:**",
2900 ] {
2901 assert!(
2902 out.contains(marker),
2903 "lockstep marker `{marker}` missing from search markdown; \
2904 update render_search_markdown when adding sidecar fields. got:\n{out}"
2905 );
2906 }
2907 }
2908
2909 #[test]
2916 fn build_entity_envelope_source_field_reads_edge_source() {
2917 let mut entity = test_entity();
2918 let body_link_target = EntityId("specs--body-link-target".to_string());
2919 let explicit_target = EntityId("specs--explicit-target".to_string());
2920 entity.relationships = vec![
2921 crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
2922 crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
2923 ];
2924
2925 let edges = vec![
2926 crate::store::Edge {
2927 rel_type: "REFERENCES".to_string(),
2928 target: body_link_target.clone(),
2929 source: crate::store::EdgeSource::BodyLink,
2930 },
2931 crate::store::Edge {
2932 rel_type: "USES".to_string(),
2933 target: explicit_target.clone(),
2934 source: crate::store::EdgeSource::Explicit,
2935 },
2936 ];
2937
2938 let env = build_entity_envelope(&entity, 0, None, None, None, &edges);
2939 let relationships = env["relationships"].as_array().expect("array");
2940 let refs = relationships
2941 .iter()
2942 .find(|r| r["rel_type"] == "REFERENCES")
2943 .expect("REFERENCES present");
2944 assert_eq!(
2945 refs["source"], "body_link",
2946 "alias-synthesised edge must label body_link"
2947 );
2948 let uses = relationships
2949 .iter()
2950 .find(|r| r["rel_type"] == "USES")
2951 .expect("USES present");
2952 assert_eq!(
2953 uses["source"], "explicit",
2954 "explicit-authored edge must label explicit"
2955 );
2956 }
2957
2958 #[test]
2963 fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
2964 let mut entity = test_entity();
2965 let target = EntityId("specs--unmapped".to_string());
2966 entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
2967 let edges: Vec<crate::store::Edge> = Vec::new();
2968 let env = build_entity_envelope(&entity, 0, None, None, None, &edges);
2969 let relationships = env["relationships"].as_array().expect("array");
2970 assert_eq!(relationships[0]["source"], "explicit");
2971 }
2972
2973 #[test]
2979 fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
2980 use crate::entity::MetadataValue;
2981 let mut entity = test_entity();
2982 entity.entity_type = "contract".to_string();
2983 entity.metadata = IndexMap::from([
2985 ("level".to_string(), MetadataValue::String("M0".to_string())),
2986 (
2987 "stability".to_string(),
2988 MetadataValue::String("stable".to_string()),
2989 ),
2990 (
2991 "created_date".to_string(),
2992 MetadataValue::String("2026-01-01".to_string()),
2993 ),
2994 (
2995 "last_modified".to_string(),
2996 MetadataValue::String("2026-05-19".to_string()),
2997 ),
2998 (
2999 "protocol".to_string(),
3000 MetadataValue::String("https".to_string()),
3001 ),
3002 (
3003 "version".to_string(),
3004 MetadataValue::String("0.1.0".to_string()),
3005 ),
3006 (
3007 "deprecation_status".to_string(),
3008 MetadataValue::String("none".to_string()),
3009 ),
3010 ]);
3011
3012 let env = build_entity_envelope(&entity, 0, None, None, None, &[]);
3013
3014 assert!(
3017 env.get("level").is_none(),
3018 "level must not be hoisted top-level"
3019 );
3020 assert!(
3021 env.get("stability").is_none(),
3022 "stability must not be hoisted"
3023 );
3024 assert!(
3025 env.get("created_date").is_none(),
3026 "created_date must not be hoisted"
3027 );
3028 assert!(
3029 env.get("last_modified").is_none(),
3030 "last_modified must not be hoisted"
3031 );
3032 assert_eq!(env["type"], "contract");
3034
3035 let metadata = env["metadata"].as_object().expect("metadata map");
3037 assert_eq!(metadata["level"], "M0");
3038 assert_eq!(metadata["stability"], "stable");
3039 assert_eq!(metadata["created_date"], "2026-01-01");
3040 assert_eq!(metadata["last_modified"], "2026-05-19");
3041 assert_eq!(metadata["protocol"], "https");
3042 assert_eq!(metadata["version"], "0.1.0");
3043 assert_eq!(metadata["deprecation_status"], "none");
3044
3045 for k in metadata.keys() {
3048 assert!(
3049 !k.starts_with('_'),
3050 "metadata map must not carry underscore-prefixed key `{k}`"
3051 );
3052 assert!(
3053 !["mem", "id", "type"].contains(&k.as_str()),
3054 "metadata map must not carry identity key `{k}` (it lives top-level)"
3055 );
3056 }
3057 }
3058
3059 #[test]
3063 fn build_entity_envelope_stub_carries_empty_metadata_map() {
3064 let mut entity = test_entity();
3065 entity.stub = true;
3066 entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
3067 entity.metadata = IndexMap::new();
3068 let env = build_entity_envelope(&entity, 0, None, None, None, &[]);
3069 let metadata = env["metadata"]
3070 .as_object()
3071 .expect("metadata key present even on stubs");
3072 assert!(metadata.is_empty(), "stub metadata map must be empty");
3073 }
3074
3075 #[test]
3082 fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
3083 use crate::entity::MetadataValue;
3084 let mut entity = test_entity();
3085 entity.metadata = IndexMap::from([
3086 (
3087 "sections".to_string(),
3088 MetadataValue::String("user-supplied-shadow".to_string()),
3089 ),
3090 (
3091 "relationships".to_string(),
3092 MetadataValue::String("also-shadowed".to_string()),
3093 ),
3094 ]);
3095 let env = build_entity_envelope(&entity, 0, None, None, None, &[]);
3096 assert!(
3098 env["sections"].is_object(),
3099 "top-level sections stays a map"
3100 );
3101 assert!(
3102 env["relationships"].is_array(),
3103 "top-level relationships stays an array"
3104 );
3105 let metadata = env["metadata"].as_object().expect("metadata map");
3107 assert_eq!(metadata["sections"], "user-supplied-shadow");
3108 assert_eq!(metadata["relationships"], "also-shadowed");
3109 }
3110
3111 #[test]
3115 fn build_entity_envelope_unfiltered_body_token_field_name() {
3116 let entity = test_entity();
3117 let env_filtered = build_entity_envelope(&entity, 10, Some(42), None, None, &[]);
3119 assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
3120 assert!(
3121 env_filtered.get("_tokens_full").is_none(),
3122 "_tokens_full must not survive — rename is one-way"
3123 );
3124 let env_unfiltered = build_entity_envelope(&entity, 10, None, None, None, &[]);
3126 assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
3127 assert!(env_unfiltered.get("_tokens_full").is_none());
3128 }
3129
3130 fn software_schema() -> Arc<Schema> {
3138 memstead_schema::builtins::load_builtin_schemas()
3139 .expect("builtins load")
3140 .into_iter()
3141 .find(|s| s.manifest.name == "software")
3142 .expect("software schema is a builtin")
3143 }
3144
3145 #[test]
3146 fn schema_verbosity_wire_round_trips() {
3147 assert_eq!(
3148 SchemaVerbosity::from_wire("full"),
3149 Some(SchemaVerbosity::Full)
3150 );
3151 assert_eq!(
3152 SchemaVerbosity::from_wire("lite"),
3153 Some(SchemaVerbosity::Lite)
3154 );
3155 assert_eq!(SchemaVerbosity::from_wire("brief"), None);
3156 assert_eq!(SchemaVerbosity::from_wire(""), None);
3157 assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
3158 assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
3159 assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
3160 }
3161
3162 #[test]
3168 fn exemplar_serves_at_full_and_lite_stays_byte_unchanged() {
3169 let manifest = r#"name: servefix
3170version: 1.0.0
3171description: serving fixture
3172when_to_use: tests
3173types:
3174 - sample
3175relationships:
3176 mode: strict
3177 definitions:
3178 - name: PART_OF
3179 description: hier
3180 default_weight: 3.0
3181 - name: _default
3182 description: fallback
3183 default_weight: 1.0
3184community:
3185 resolution: 1.0
3186 seed: 42
3187"#;
3188 let base_type = r#"name: sample
3189description: t
3190when_to_use: tests
3191sections:
3192 - key: body
3193 heading: Body
3194 required: true
3195 search_weight: 10.0
3196 catch_all: true
3197 write_rules: []
3198metadata_fields:
3199 - key: status
3200 description: state
3201 field_type: string
3202 enum_values: [draft, final]
3203 optional: true
3204title_weight: 100.0
3205text_fields:
3206 - body
3207hierarchy_relationship: PART_OF
3208no_self_loop_relationships: []
3209updatable_fields:
3210 - title
3211 - body
3212health_required_fields:
3213 - body
3214staleness_threshold_days: 90
3215write_rules: []
3216"#;
3217 let with_exemplar = format!(
3218 "{base_type}exemplar:\n title: A Conforming Sample\n metadata:\n status: draft\n sections:\n body: \"One canonical body paragraph.\"\n relations:\n - to: parent-placeholder\n type: PART_OF\n"
3219 );
3220
3221 let plain = Arc::new(
3222 memstead_schema::loader::load_schema_from_memory(
3223 manifest,
3224 &[("sample".to_string(), base_type.to_string())],
3225 )
3226 .expect("fixture loads"),
3227 );
3228 let exemplary = Arc::new(
3229 memstead_schema::loader::load_schema_from_memory(
3230 manifest,
3231 &[("sample".to_string(), with_exemplar)],
3232 )
3233 .expect("fixture loads"),
3234 );
3235
3236 let full = build_schema_payload(
3238 &exemplary,
3239 vec![],
3240 SchemaVerbosity::Full,
3241 OriginClass::FirstParty,
3242 );
3243 let ex = &full["types"][0]["exemplar"];
3244 assert_eq!(ex["title"], "A Conforming Sample", "{full}");
3245 assert_eq!(ex["metadata"]["status"], "draft");
3246 assert_eq!(ex["sections"]["body"], "One canonical body paragraph.");
3247 assert_eq!(ex["relations"][0]["to"], "parent-placeholder");
3248 assert_eq!(ex["relations"][0]["type"], "PART_OF");
3249
3250 let full_plain = build_schema_payload(
3252 &plain,
3253 vec![],
3254 SchemaVerbosity::Full,
3255 OriginClass::FirstParty,
3256 );
3257 assert!(full_plain["types"][0].get("exemplar").is_none());
3258
3259 let lite_with = build_schema_payload(
3262 &exemplary,
3263 vec![],
3264 SchemaVerbosity::Lite,
3265 OriginClass::FirstParty,
3266 );
3267 let lite_without = build_schema_payload(
3268 &plain,
3269 vec![],
3270 SchemaVerbosity::Lite,
3271 OriginClass::FirstParty,
3272 );
3273 assert_eq!(
3274 serde_json::to_string(&lite_with).unwrap(),
3275 serde_json::to_string(&lite_without).unwrap(),
3276 "lite must not change when an exemplar exists"
3277 );
3278 assert!(
3279 !serde_json::to_string(&lite_with)
3280 .unwrap()
3281 .contains("exemplar"),
3282 "lite must not mention exemplars at all"
3283 );
3284 }
3285
3286 #[test]
3290 fn first_party_origin_is_labelled_and_keeps_prose() {
3291 let schema = software_schema();
3292 let full = build_schema_payload(
3293 &schema,
3294 vec!["v".into()],
3295 SchemaVerbosity::Full,
3296 OriginClass::FirstParty,
3297 );
3298 assert_eq!(full["origin"], "first-party");
3299 assert!(full["description"].is_string());
3301 let t = &full["types"].as_array().unwrap()[0];
3302 assert!(t.get("system_context").is_some());
3303 assert!(t.get("writing_guidance").is_some());
3304
3305 let lite = build_schema_payload(
3307 &schema,
3308 vec!["v".into()],
3309 SchemaVerbosity::Lite,
3310 OriginClass::FirstParty,
3311 );
3312 assert_eq!(lite["origin"], "first-party");
3313 }
3314
3315 #[test]
3320 fn constraints_and_severity_render_at_both_verbosities() {
3321 let manifest = r#"name: constrained
3322version: 1.0.0
3323description: constraint render fixture
3324when_to_use: render tests
3325types:
3326 - sample
3327relationships:
3328 mode: strict
3329 definitions:
3330 - name: PART_OF
3331 description: hier
3332 default_weight: 3.0
3333 - name: _default
3334 description: fallback
3335 default_weight: 1.0
3336community:
3337 resolution: 1.0
3338 seed: 42
3339"#;
3340 let type_yaml = r#"name: sample
3341description: t
3342when_to_use: tests
3343sections:
3344 - key: body
3345 heading: Body
3346 required: true
3347 search_weight: 10.0
3348 catch_all: true
3349 write_rules: []
3350metadata_fields:
3351 - key: status
3352 description: state
3353 field_type: string
3354 enum_values: [open, checked]
3355 optional: true
3356 - key: checked_by
3357 description: who
3358 field_type: string
3359 optional: true
3360title_weight: 100.0
3361text_fields:
3362 - body
3363hierarchy_relationship: PART_OF
3364no_self_loop_relationships: []
3365updatable_fields:
3366 - title
3367 - body
3368health_required_fields:
3369 - body
3370staleness_threshold_days: 90
3371required_outgoing:
3372 - relationships: [PART_OF]
3373 cardinality: at_least_one
3374 severity: block
3375constraints:
3376 - kind: requires_when
3377 field: checked_by
3378 when_field: status
3379 when_value: checked
3380 - kind: unique
3381 fields: [status, checked_by]
3382 - kind: enum_from_neighbour
3383 field: status
3384 rel_type: PART_OF
3385 section: body
3386 - kind: status_propagation
3387 field: status
3388 value: checked
3389 rel_type: PART_OF
3390 direction: incoming
3391write_rules: []
3392"#;
3393 let schema = Arc::new(
3394 memstead_schema::loader::load_schema_from_memory(
3395 manifest,
3396 &[("sample".to_string(), type_yaml.to_string())],
3397 )
3398 .expect("fixture loads"),
3399 );
3400
3401 let expected_constraints = serde_json::json!([
3406 {
3407 "kind": "requires_when",
3408 "field": "checked_by",
3409 "when_field": "status",
3410 "when_value": "checked",
3411 "severity": "warn",
3412 },
3413 {
3414 "kind": "unique",
3415 "fields": ["status", "checked_by"],
3416 "severity": "block",
3417 },
3418 {
3419 "kind": "enum_from_neighbour",
3420 "field": "status",
3421 "rel_type": "PART_OF",
3422 "section": "body",
3423 "severity": "warn",
3424 },
3425 {
3426 "kind": "status_propagation",
3427 "field": "status",
3428 "value": "checked",
3429 "rel_type": "PART_OF",
3430 "direction": "incoming",
3431 "severity": "warn",
3432 },
3433 ]);
3434
3435 let full = build_schema_payload(
3436 &schema,
3437 vec![],
3438 SchemaVerbosity::Full,
3439 OriginClass::FirstParty,
3440 );
3441 let t = &full["types"].as_array().unwrap()[0];
3442 assert_eq!(t["constraints"], expected_constraints);
3443 assert_eq!(t["required_outgoing"][0]["severity"], "block");
3444
3445 let lite = build_schema_payload(
3446 &schema,
3447 vec![],
3448 SchemaVerbosity::Lite,
3449 OriginClass::FirstParty,
3450 );
3451 let ts = &lite["types_summary"].as_array().unwrap()[0];
3452 assert_eq!(ts["constraints"], expected_constraints);
3453 assert_eq!(ts["required_outgoing"][0]["severity"], "block");
3454
3455 let fmt_manifest = r#"name: formatted
3458version: 1.0.0
3459description: format render fixture
3460when_to_use: render tests
3461types:
3462 - plan
3463relationships:
3464 mode: strict
3465 definitions:
3466 - name: PART_OF
3467 description: hier
3468 default_weight: 1.0
3469 - name: _default
3470 description: fallback
3471 default_weight: 1.0
3472community:
3473 resolution: 1.0
3474 seed: 42
3475"#;
3476 let fmt_type = r#"name: plan
3477description: t
3478when_to_use: tests
3479sections:
3480 - key: body
3481 heading: Body
3482 required: true
3483 search_weight: 10.0
3484 catch_all: true
3485 write_rules: []
3486 - key: meilensteine
3487 heading: Meilensteine
3488 required: false
3489 search_weight: 5.0
3490 catch_all: false
3491 write_rules: []
3492 content: "(heading(3) list(bullet))+"
3493 item_pattern: '\*\*(?<name>[^*]+)\*\*'
3494 example: |
3495 ### Phase 1
3496 - **Kickoff**
3497 format_severity: warn
3498 - key: tabelle
3499 heading: Tabelle
3500 required: false
3501 search_weight: 5.0
3502 catch_all: false
3503 write_rules: []
3504 content: "table"
3505 table:
3506 columns: [Name, Datum]
3507 column_patterns:
3508 Datum: '\d{4}-\d{2}-\d{2}'
3509 - key: belege
3510 heading: Belege
3511 required: false
3512 search_weight: 5.0
3513 catch_all: false
3514 write_rules: []
3515 content: "paragraph+"
3516 item_pattern: '(?<quelle>\S[^|]*?) \| (?<aussage>.+)'
3517metadata_fields: []
3518title_weight: 100.0
3519text_fields:
3520 - body
3521hierarchy_relationship: PART_OF
3522no_self_loop_relationships: []
3523updatable_fields:
3524 - title
3525 - body
3526health_required_fields:
3527 - body
3528staleness_threshold_days: 90
3529write_rules: []
3530"#;
3531 let fmt_schema = Arc::new(
3532 memstead_schema::loader::load_schema_from_memory(
3533 fmt_manifest,
3534 &[("plan".to_string(), fmt_type.to_string())],
3535 )
3536 .expect("format fixture loads"),
3537 );
3538 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
3539 let payload =
3540 build_schema_payload(&fmt_schema, vec![], verbosity, OriginClass::FirstParty);
3541 let sections_key = match verbosity {
3542 SchemaVerbosity::Full => &payload["types"][0]["sections"],
3543 SchemaVerbosity::Lite => &payload["types_summary"][0]["sections"],
3544 };
3545 let secs = sections_key.as_array().unwrap();
3546 let meilensteine = secs
3547 .iter()
3548 .find(|s| s["key"] == "meilensteine")
3549 .expect("declared section present");
3550 assert_eq!(
3551 meilensteine["content"], "(heading(3) list(bullet))+",
3552 "{verbosity:?} carries content"
3553 );
3554 assert!(
3555 meilensteine["item_pattern"]
3556 .as_str()
3557 .unwrap()
3558 .contains("name")
3559 );
3560 assert!(
3561 meilensteine["example"]
3562 .as_str()
3563 .unwrap()
3564 .contains("Kickoff")
3565 );
3566 assert_eq!(meilensteine["format_severity"], "warn");
3567 let tabelle = secs.iter().find(|s| s["key"] == "tabelle").unwrap();
3568 assert_eq!(tabelle["format_severity"], "block", "default renders");
3569 assert_eq!(tabelle["table"]["columns"][0], "Name");
3570 assert!(
3571 tabelle["table"]["column_patterns"]["Datum"]
3572 .as_str()
3573 .is_some()
3574 );
3575 let belege = secs.iter().find(|s| s["key"] == "belege").unwrap();
3576 assert_eq!(belege["content"], "paragraph+");
3577 assert!(belege["item_pattern"].as_str().unwrap().contains("quelle"));
3578 let body = secs.iter().find(|s| s["key"] == "body").unwrap();
3579 assert!(
3580 body.get("content").is_none() && body.get("format_severity").is_none(),
3581 "undeclared section keeps its pre-plan shape"
3582 );
3583 }
3584
3585 let plain_full = build_schema_payload(
3588 &software_schema(),
3589 vec![],
3590 SchemaVerbosity::Full,
3591 OriginClass::FirstParty,
3592 );
3593 let pt = &plain_full["types"].as_array().unwrap()[0];
3594 assert_eq!(pt["constraints"], serde_json::json!([]));
3595 let plain_lite = build_schema_payload(
3596 &software_schema(),
3597 vec![],
3598 SchemaVerbosity::Lite,
3599 OriginClass::FirstParty,
3600 );
3601 let pts = &plain_lite["types_summary"].as_array().unwrap()[0];
3602 assert_eq!(pts["constraints"], serde_json::json!([]));
3603 }
3604
3605 #[test]
3615 fn third_party_origin_forces_structural_only_even_under_full() {
3616 let schema = software_schema();
3617 let full_requested = build_schema_payload(
3618 &schema,
3619 vec!["v".into()],
3620 SchemaVerbosity::Full,
3621 OriginClass::ThirdParty,
3622 );
3623
3624 assert_eq!(full_requested["origin"], "third-party");
3626
3627 assert!(
3630 full_requested.get("types").is_none(),
3631 "third-party omits the rich `types` array even under full"
3632 );
3633 assert!(
3634 full_requested.get("relationships").is_none(),
3635 "third-party omits the rich `relationships` array even under full"
3636 );
3637 assert!(
3638 full_requested["types_summary"].is_array(),
3639 "third-party serves the structural `types_summary` skeleton"
3640 );
3641 assert!(
3642 full_requested["relationships_summary"].is_array(),
3643 "third-party serves the structural `relationships_summary` skeleton"
3644 );
3645
3646 assert!(
3648 full_requested.get("description").is_none(),
3649 "third-party drops schema description prose"
3650 );
3651 assert!(
3652 full_requested.get("when_to_use").is_none(),
3653 "third-party drops schema when_to_use prose"
3654 );
3655 assert!(
3656 full_requested.get("default_writing_guidance").is_none(),
3657 "third-party drops default_writing_guidance prose"
3658 );
3659
3660 for t in full_requested["types_summary"].as_array().unwrap() {
3662 assert!(
3663 t.get("system_context").is_none(),
3664 "third-party drops system_context"
3665 );
3666 assert!(
3667 t.get("writing_guidance").is_none(),
3668 "third-party drops writing_guidance"
3669 );
3670 assert!(
3671 t.get("description").is_none(),
3672 "third-party drops type description"
3673 );
3674 for s in t["sections"].as_array().unwrap() {
3675 assert!(
3676 s.get("write_rules").is_none(),
3677 "third-party drops section write_rules"
3678 );
3679 }
3680 }
3681 for r in full_requested["relationships_summary"].as_array().unwrap() {
3683 assert!(
3684 r.get("description").is_none(),
3685 "third-party drops rel description"
3686 );
3687 assert!(
3688 r.get("when_to_use").is_none(),
3689 "third-party drops rel when_to_use"
3690 );
3691 }
3692
3693 let lite_requested = build_schema_payload(
3697 &schema,
3698 vec!["v".into()],
3699 SchemaVerbosity::Lite,
3700 OriginClass::ThirdParty,
3701 );
3702 assert_eq!(
3703 full_requested, lite_requested,
3704 "third-party full must collapse to the lite skeleton"
3705 );
3706 }
3707
3708 #[test]
3709 fn full_payload_carries_the_rich_arrays_and_prose() {
3710 let schema = software_schema();
3711 let full = build_schema_payload(
3712 &schema,
3713 vec!["v".into()],
3714 SchemaVerbosity::Full,
3715 OriginClass::FirstParty,
3716 );
3717
3718 assert!(full["types"].is_array(), "full has `types`");
3720 assert!(full["relationships"].is_array(), "full has `relationships`");
3721 assert!(
3722 full.get("types_summary").is_none(),
3723 "full omits `types_summary`"
3724 );
3725 assert!(
3726 full.get("relationships_summary").is_none(),
3727 "full omits `relationships_summary`"
3728 );
3729 assert!(
3730 full["description"].is_string(),
3731 "full keeps schema description"
3732 );
3733 assert!(
3734 full["when_to_use"].is_string(),
3735 "full keeps schema when_to_use"
3736 );
3737 assert_eq!(full["alias_target_rel_type"], "REFERENCES");
3738
3739 let t = &full["types"].as_array().unwrap()[0];
3741 assert!(t["description"].is_string());
3742 assert!(t.get("writing_guidance").is_some());
3743 assert!(t.get("system_context").is_some());
3744 let r = &full["relationships"].as_array().unwrap()[0];
3746 assert!(r["description"].is_string());
3747 assert!(r.get("when_to_use").is_some());
3748 assert!(r.get("default_weight").is_some());
3749 }
3750
3751 #[test]
3760 fn required_outgoing_reported_with_cardinality_at_both_levels() {
3761 let reg = memstead_schema::SchemaRegistry::builtin();
3762 let project = reg
3763 .get("project", &semver::Version::new(0, 2, 0))
3764 .expect("project is a built-in");
3765
3766 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
3767 let payload =
3768 build_schema_payload(&project, vec![], verbosity, OriginClass::FirstParty);
3769 let types_key = if verbosity == SchemaVerbosity::Full {
3770 "types"
3771 } else {
3772 "types_summary"
3773 };
3774 let types = payload[types_key].as_array().expect("types array");
3775
3776 let mut saw_evidence = false;
3777 let mut saw_memo = false;
3778 for t in types {
3779 let ro = t
3780 .get("required_outgoing")
3781 .unwrap_or_else(|| panic!("type {} omits required_outgoing", t["name"]))
3782 .as_array()
3783 .expect("required_outgoing is an array for every type");
3784 if t["name"] == "evidence" {
3785 saw_evidence = true;
3786 assert_eq!(ro.len(), 1, "evidence declares one block");
3787 assert_eq!(
3788 ro[0]["relationships"],
3789 serde_json::json!(["STRENGTHENS", "WEAKENS", "VALIDATES", "CONTRADICTS"]),
3790 "relationship alternatives in declaration order"
3791 );
3792 assert_eq!(
3793 ro[0]["cardinality"], "at_least_one",
3794 "cardinality rendered as declared — the open upper bound \
3795 stays open, never a finite number"
3796 );
3797 } else if t["name"] == "memo" {
3798 saw_memo = true;
3801 assert!(ro.is_empty(), "memo declares no blocks → empty list");
3802 }
3803 }
3804 assert!(saw_evidence, "project schema carries the evidence type");
3805 assert!(saw_memo, "project schema carries the memo type");
3806
3807 let note = payload["no_self_loop_relationships_effect"]
3810 .as_str()
3811 .expect("effect note present at both verbosity levels");
3812 assert!(note.contains("self-loop"), "names the actual effect");
3813 assert!(
3814 !note.contains("propagates impact") || note.contains("does not propagate"),
3815 "claims no propagation behaviour beyond the self-loop refusal"
3816 );
3817 assert!(
3818 note.contains("status_propagation"),
3819 "deprecation pointer names the real propagation declaration"
3820 );
3821 }
3822 }
3823
3824 #[test]
3825 fn lite_payload_is_the_structural_skeleton_without_prose() {
3826 let schema = software_schema();
3827 let lite = build_schema_payload(
3828 &schema,
3829 vec!["v".into()],
3830 SchemaVerbosity::Lite,
3831 OriginClass::FirstParty,
3832 );
3833
3834 let types = lite["types_summary"]
3836 .as_array()
3837 .expect("lite has `types_summary`");
3838 let rels = lite["relationships_summary"]
3839 .as_array()
3840 .expect("lite has `relationships_summary`");
3841 assert!(lite.get("types").is_none(), "lite omits rich `types`");
3842 assert!(
3843 lite.get("relationships").is_none(),
3844 "lite omits rich `relationships`"
3845 );
3846
3847 assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
3850
3851 assert!(
3853 lite.get("description").is_none(),
3854 "lite drops schema description"
3855 );
3856 assert!(
3857 lite.get("when_to_use").is_none(),
3858 "lite drops schema when_to_use"
3859 );
3860 assert!(
3861 lite.get("default_writing_guidance").is_none(),
3862 "lite drops default_writing_guidance"
3863 );
3864
3865 for t in types {
3868 assert!(t["name"].is_string());
3869 let sections = t["sections"].as_array().expect("lite type has sections");
3870 for s in sections {
3871 assert!(s["key"].is_string(), "section carries its key");
3872 assert!(s["required"].is_boolean(), "section carries required flag");
3873 assert!(
3874 s.get("write_rules").is_none(),
3875 "lite section drops write_rules prose"
3876 );
3877 assert!(s.get("heading").is_none(), "lite section drops heading");
3878 }
3879 assert!(
3880 t.get("description").is_none(),
3881 "lite type drops description"
3882 );
3883 assert!(
3884 t.get("writing_guidance").is_none(),
3885 "lite type drops writing_guidance"
3886 );
3887 assert!(
3888 t.get("system_context").is_none(),
3889 "lite type drops system_context"
3890 );
3891 assert!(
3895 t.get("no_self_loop_relationships").is_some(),
3896 "lite type keeps no_self_loop_relationships"
3897 );
3898 assert!(
3902 t.get("required_outgoing").is_some_and(|v| v.is_array()),
3903 "lite type keeps required_outgoing as an array"
3904 );
3905 if let Some(fields) = t["fields"].as_array() {
3907 for f in fields {
3908 assert!(f["name"].is_string());
3909 assert!(f["required"].is_boolean());
3910 assert!(
3911 f.get("description").is_none(),
3912 "lite field drops description"
3913 );
3914 }
3915 }
3916 }
3917
3918 for r in rels {
3921 assert!(r["name"].is_string());
3922 assert!(
3923 r.get("allowed_sources").is_some(),
3924 "lite rel has allowed_sources"
3925 );
3926 assert!(
3927 r.get("allowed_targets").is_some(),
3928 "lite rel has allowed_targets"
3929 );
3930 assert!(
3931 r.get("manual_authoring").is_some(),
3932 "lite rel keeps manual_authoring"
3933 );
3934 assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
3935 assert!(
3936 r.get("per_edge_description").is_some(),
3937 "lite rel keeps per_edge_description"
3938 );
3939 assert!(r.get("description").is_none(), "lite rel drops description");
3940 assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
3941 assert!(
3942 r.get("default_weight").is_none(),
3943 "lite rel drops default_weight"
3944 );
3945 }
3946 }
3947
3948 #[test]
3949 fn lite_is_measurably_smaller_than_full() {
3950 let schema = software_schema();
3951 let full = build_schema_payload(
3952 &schema,
3953 vec!["v".into()],
3954 SchemaVerbosity::Full,
3955 OriginClass::FirstParty,
3956 );
3957 let lite = build_schema_payload(
3958 &schema,
3959 vec!["v".into()],
3960 SchemaVerbosity::Lite,
3961 OriginClass::FirstParty,
3962 );
3963 let full_len = serde_json::to_string(&full).unwrap().len();
3964 let lite_len = serde_json::to_string(&lite).unwrap().len();
3965 assert!(
3966 lite_len * 2 < full_len,
3967 "lite ({lite_len} B) must be well under half of full ({full_len} B)"
3968 );
3969 }
3970
3971 #[test]
3972 fn lite_full_carry_the_same_type_and_rel_names() {
3973 let schema = software_schema();
3976 let full = build_schema_payload(
3977 &schema,
3978 vec!["v".into()],
3979 SchemaVerbosity::Full,
3980 OriginClass::FirstParty,
3981 );
3982 let lite = build_schema_payload(
3983 &schema,
3984 vec!["v".into()],
3985 SchemaVerbosity::Lite,
3986 OriginClass::FirstParty,
3987 );
3988
3989 let names = |arr: &serde_json::Value| -> Vec<String> {
3990 arr.as_array()
3991 .unwrap()
3992 .iter()
3993 .map(|v| v["name"].as_str().unwrap().to_string())
3994 .collect()
3995 };
3996 assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
3997 assert_eq!(
3998 names(&full["relationships"]),
3999 names(&lite["relationships_summary"])
4000 );
4001 }
4002}