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
665#[allow(clippy::too_many_arguments)] pub fn build_entity_envelope(
700 entity: &Entity,
701 rendered_body_tokens: usize,
702 full_tokens: Option<usize>,
703 sections_filter: Option<&[String]>,
704 schema_anchor: Option<&str>,
705 origin: OriginClass,
706 outgoing_edges: &[crate::store::Edge],
707 incoming_edges: Option<&[crate::store::InEdge]>,
708) -> serde_json::Value {
709 let mut envelope = serde_json::Map::new();
710 envelope.insert(
711 "_hash".to_string(),
712 serde_json::Value::String(entity.content_hash.clone()),
713 );
714 envelope.insert(
721 "origin".to_string(),
722 serde_json::Value::String(origin.as_wire().to_string()),
723 );
724 envelope.insert(
725 "id".to_string(),
726 serde_json::Value::String(entity.id.to_string()),
727 );
728 envelope.insert(
729 "mem".to_string(),
730 serde_json::Value::String(entity.mem.clone()),
731 );
732 envelope.insert(
733 "type".to_string(),
734 serde_json::Value::String(entity.entity_type.clone()),
735 );
736 envelope.insert(
741 "title".to_string(),
742 serde_json::Value::String(entity.title.clone()),
743 );
744
745 let mut metadata = serde_json::Map::new();
761 for (key, value) in &entity.metadata {
762 if key.starts_with('_')
763 || crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str())
764 {
765 continue;
766 }
767 metadata.insert(
768 key.clone(),
769 serde_json::Value::String(value.to_frontmatter_string()),
770 );
771 }
772 envelope.insert("metadata".to_string(), serde_json::Value::Object(metadata));
773
774 envelope.insert(
775 "_tokens".to_string(),
776 serde_json::Value::Number(serde_json::Number::from(rendered_body_tokens)),
777 );
778 if let Some(t) = full_tokens {
779 envelope.insert(
786 "_tokens_unfiltered_body".to_string(),
787 serde_json::Value::Number(serde_json::Number::from(t)),
788 );
789 }
790 if let Some(s) = schema_anchor {
791 envelope.insert(
792 "_mem_schema".to_string(),
793 serde_json::Value::String(s.to_string()),
794 );
795 }
796
797 if let Some(kind) = &entity.stub_kind {
798 envelope.insert(
799 "_stub_kind".to_string(),
800 serde_json::to_value(kind).unwrap_or(serde_json::Value::Null),
801 );
802 }
803
804 let mut sections = serde_json::Map::new();
805 for (key, content) in &entity.sections {
806 if let Some(filter) = sections_filter
807 && !filter.iter().any(|f| f == key)
808 {
809 continue;
810 }
811 sections.insert(key.clone(), serde_json::Value::String(content.clone()));
812 }
813 envelope.insert("sections".to_string(), serde_json::Value::Object(sections));
814
815 let resolve_source = |rel: &crate::entity::Relationship| -> &'static str {
826 outgoing_edges
827 .iter()
828 .find(|e| e.rel_type == rel.rel_type && e.target == rel.target)
829 .map(|e| match e.source {
830 crate::store::EdgeSource::BodyLink => "body_link",
831 crate::store::EdgeSource::Hierarchy => "hierarchy",
832 crate::store::EdgeSource::Explicit => "explicit",
833 })
834 .unwrap_or("explicit")
835 };
836 let mut relationships: Vec<serde_json::Value> = entity
844 .relationships
845 .iter()
846 .map(|rel| {
847 let mut obj = serde_json::Map::new();
848 obj.insert(
849 "rel_type".to_string(),
850 serde_json::Value::String(rel.rel_type.clone()),
851 );
852 obj.insert(
853 "target".to_string(),
854 serde_json::Value::String(rel.target.to_string()),
855 );
856 obj.insert(
857 "direction".to_string(),
858 serde_json::Value::String("out".to_string()),
859 );
860 obj.insert(
861 "source".to_string(),
862 serde_json::Value::String(resolve_source(rel).to_string()),
863 );
864 if let Some(desc) = rel
865 .description
866 .as_deref()
867 .map(str::trim)
868 .filter(|s| !s.is_empty())
869 {
870 obj.insert(
871 "description".to_string(),
872 serde_json::Value::String(desc.to_string()),
873 );
874 }
875 serde_json::Value::Object(obj)
876 })
877 .collect();
878 if let Some(incoming) = incoming_edges {
879 for e in incoming {
880 let mut obj = serde_json::Map::new();
881 obj.insert(
882 "rel_type".to_string(),
883 serde_json::Value::String(e.rel_type.clone()),
884 );
885 obj.insert(
886 "from".to_string(),
887 serde_json::Value::String(e.from.to_string()),
888 );
889 obj.insert(
890 "direction".to_string(),
891 serde_json::Value::String("in".to_string()),
892 );
893 obj.insert(
894 "source".to_string(),
895 serde_json::Value::String(
896 match e.source {
897 crate::store::EdgeSource::BodyLink => "body_link",
898 crate::store::EdgeSource::Hierarchy => "hierarchy",
899 crate::store::EdgeSource::Explicit => "explicit",
900 }
901 .to_string(),
902 ),
903 );
904 relationships.push(serde_json::Value::Object(obj));
905 }
906 }
907 envelope.insert(
908 "relationships".to_string(),
909 serde_json::Value::Array(relationships),
910 );
911
912 serde_json::Value::Object(envelope)
913}
914
915pub fn build_search_envelope<'a>(
917 result: &'a SearchResult,
918 offset: usize,
919) -> SearchResultEnvelope<'a> {
920 SearchResultEnvelope {
921 total: result.total,
922 returned: result.returned,
923 offset,
924 total_tokens: result.total_tokens,
925 hits: result.hits.iter().map(build_hit_envelope).collect(),
926 facets: result.facets.as_ref(),
927 warnings: &result.warnings,
928 }
929}
930
931pub fn build_list_envelope(result: &ListResult) -> ListResultEnvelope<'_> {
933 ListResultEnvelope {
934 total: result.total,
935 returned: result.returned,
936 offset: result.offset,
937 total_tokens: result.total_tokens,
938 hits: result.hits.iter().map(build_hit_envelope).collect(),
939 warnings: &result.warnings,
940 }
941}
942
943fn build_hit_envelope(hit: &SearchHit) -> SearchHitEnvelope<'_> {
944 let (heading, value) = hit_summary_pair(hit);
945 SearchHitEnvelope {
946 hit,
947 summary_heading: heading,
948 summary_value: value,
949 }
950}
951
952fn hit_summary_line(hit: &SearchHit) -> String {
962 let (heading, value) = hit_summary_pair(hit);
963 format!("**{heading}**: {value}")
964}
965
966fn hit_summary_pair(hit: &SearchHit) -> (String, String) {
976 if let Some(summary) = &hit.summary {
977 return (summary.heading.clone(), summary.value.clone());
978 }
979 summary_pair(type_by_name(&hit.entity_type).as_deref(), &hit.sections)
980}
981
982fn summary_pair(
984 schema: Option<&TypeDefinition>,
985 sections: &HashMap<String, String>,
986) -> (String, String) {
987 match schema {
988 Some(schema) => lead_section_pair(schema, |k| sections.get(k).map(String::as_str)),
989 None => ("Summary".to_string(), "—".to_string()),
990 }
991}
992
993pub(crate) fn lead_section_pair<'a>(
1001 schema: &TypeDefinition,
1002 get_section: impl Fn(&str) -> Option<&'a str>,
1003) -> (String, String) {
1004 let Some(section) = schema
1005 .required_sections()
1006 .next()
1007 .or(schema.sections.first())
1008 else {
1009 return ("Summary".to_string(), "—".to_string());
1010 };
1011 let value = get_section(section.key.as_str()).unwrap_or("—");
1012 (section.heading.clone(), value.to_string())
1013}
1014
1015fn section_key_to_heading(key: &str) -> String {
1019 let mut chars = key.chars();
1020 match chars.next() {
1021 None => String::new(),
1022 Some(c) => {
1023 let first: String = c.to_uppercase().collect();
1024 let rest: String = chars.map(|c| if c == '_' { ' ' } else { c }).collect();
1025 format!("{first}{rest}")
1026 }
1027 }
1028}
1029
1030fn section_heading_for(type_def: Option<&TypeDefinition>, key: &str) -> String {
1037 type_def
1038 .and_then(|t| t.sections.iter().find(|s| s.key == key))
1039 .map(|s| s.heading.clone())
1040 .unwrap_or_else(|| section_key_to_heading(key))
1041}
1042
1043fn lookup_builtin_type(name: &str) -> Option<Arc<TypeDefinition>> {
1052 static CACHE: OnceLock<Vec<Arc<Schema>>> = OnceLock::new();
1053 let schemas =
1054 CACHE.get_or_init(|| memstead_schema::builtins::load_builtin_schemas().unwrap_or_default());
1055 for s in schemas {
1056 if let Some(t) = s.get_type(name) {
1057 return Some(t);
1058 }
1059 }
1060 None
1061}
1062
1063pub fn render_type_catalog_markdown() -> String {
1069 render_type_catalog_lines(all_types())
1070}
1071
1072pub fn render_type_catalog_markdown_for(schema: &Schema) -> String {
1078 let mut types: Vec<Arc<TypeDefinition>> = schema.types.values().cloned().collect();
1079 types.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
1080 render_type_catalog_lines(types)
1081}
1082
1083fn render_type_catalog_lines(types: Vec<Arc<TypeDefinition>>) -> String {
1084 let mut lines = vec![
1085 "# Available types".to_string(),
1086 String::new(),
1087 "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."
1088 .to_string(),
1089 String::new(),
1090 ];
1091 for schema in types {
1092 let required_sections = schema.required_sections().count();
1093 let total_sections = schema.sections.len();
1094 let metadata_count = schema.metadata_fields.len();
1095 lines.push(format!(
1096 "- **{}** — {} sections ({} required), {} metadata fields, staleness {}d",
1097 schema.name.as_str(),
1098 total_sections,
1099 required_sections,
1100 metadata_count,
1101 schema.staleness_threshold_days,
1102 ));
1103 }
1104 lines.push(String::new());
1105 lines.join("\n")
1106}
1107
1108pub fn render_type_info_markdown(schema: &TypeDefinition) -> String {
1110 let mut lines = Vec::new();
1111 lines.push(format!("# Type: {}", schema.name.as_str()));
1112 lines.push(String::new());
1113 lines.push(format!(
1114 "Staleness threshold: {} days. Hierarchy: `{}`.",
1115 schema.staleness_threshold_days, schema.hierarchy_relationship,
1116 ));
1117 lines.push(String::new());
1118
1119 lines.push("## Metadata fields".to_string());
1121 for field in &schema.metadata_fields {
1122 lines.push(format!("- {}", describe_metadata_field(field)));
1123 }
1124 lines.push(String::new());
1125
1126 lines.push("## Sections".to_string());
1128 for section in &schema.sections {
1129 let req = if section.required {
1130 "required"
1131 } else {
1132 "optional"
1133 };
1134 let catch_all = if section.catch_all { ", catch-all" } else { "" };
1135 lines.push(format!(
1136 "- **{}** ({req}{catch_all}, search_weight: {:.1})",
1137 section.key, section.search_weight,
1138 ));
1139 for rule in §ion.write_rules {
1140 lines.push(format!(" - Write rule: {rule}"));
1141 }
1142 }
1143 lines.push(String::new());
1144
1145 lines.push("## Relationship types (with edge weights)".to_string());
1147 for (rel_type, weight) in &schema.edge_weights {
1148 if rel_type == "_default" {
1149 continue;
1150 }
1151 let mut flags: Vec<&str> = Vec::new();
1152 if rel_type == &schema.hierarchy_relationship {
1153 flags.push("hierarchy");
1154 }
1155 if schema
1156 .no_self_loop_relationships
1157 .iter()
1158 .any(|r| r == rel_type)
1159 {
1160 flags.push("no-self-loop");
1161 }
1162 let flag_str = if flags.is_empty() {
1163 String::new()
1164 } else {
1165 format!(" ({})", flags.join(", "))
1166 };
1167 lines.push(format!("- **{rel_type}**: {weight}{flag_str}"));
1168 }
1169 if let Some((_, default_weight)) = schema.edge_weights.iter().find(|(n, _)| *n == "_default") {
1171 lines.push(format!(
1172 "- _default_ (any other relationship type): {default_weight}"
1173 ));
1174 }
1175 lines.push(String::new());
1176
1177 if !schema.write_rules.is_empty() {
1179 lines.push("## Writing guidance".to_string());
1180 for rule in &schema.write_rules {
1181 lines.push(format!("- {rule}"));
1182 }
1183 lines.push(String::new());
1184 }
1185
1186 let system_msg = schema.system_message_str();
1188 if !system_msg.is_empty() {
1189 lines.push("## System context".to_string());
1190 lines.push(system_msg.to_string());
1191 lines.push(String::new());
1192 }
1193
1194 if let Some(ex) = &schema.exemplar {
1198 lines.push("## Exemplar (engine-validated)".to_string());
1199 lines.push(String::new());
1200 lines.push(format!("Title: {}", ex.title));
1201 if !ex.metadata.is_empty() {
1202 lines.push("Metadata:".to_string());
1203 for (k, v) in &ex.metadata {
1204 lines.push(format!("- {k}: {v}"));
1205 }
1206 }
1207 for (key, body) in &ex.sections {
1208 let heading = schema
1209 .section(key)
1210 .map(|s| s.heading.clone())
1211 .unwrap_or_else(|| key.clone());
1212 lines.push(format!("### {heading}"));
1213 lines.push(body.clone());
1214 }
1215 if !ex.relations.is_empty() {
1216 lines.push("Relations (placeholder targets):".to_string());
1217 for r in &ex.relations {
1218 match &r.description {
1219 Some(d) => lines.push(format!("- {} → {} — {d}", r.rel_type, r.to)),
1220 None => lines.push(format!("- {} → {}", r.rel_type, r.to)),
1221 }
1222 }
1223 }
1224 lines.push(String::new());
1225 }
1226
1227 lines.join("\n")
1228}
1229
1230pub fn per_edge_description_str(p: PerEdgeDescription) -> &'static str {
1236 match p {
1237 PerEdgeDescription::Forbidden => "forbidden",
1238 PerEdgeDescription::Optional => "optional",
1239 PerEdgeDescription::Required => "required",
1240 }
1241}
1242
1243pub fn manual_authoring_str(p: ManualAuthoring) -> &'static str {
1245 match p {
1246 ManualAuthoring::Allow => "allow",
1247 ManualAuthoring::Warn => "warn",
1248 ManualAuthoring::Forbidden => "forbidden",
1249 }
1250}
1251
1252#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1268pub enum SchemaVerbosity {
1269 #[default]
1270 Full,
1271 Lite,
1272}
1273
1274impl SchemaVerbosity {
1275 pub fn from_wire(s: &str) -> Option<Self> {
1280 match s {
1281 "full" => Some(Self::Full),
1282 "lite" => Some(Self::Lite),
1283 _ => None,
1284 }
1285 }
1286
1287 pub fn as_wire(self) -> &'static str {
1289 match self {
1290 Self::Full => "full",
1291 Self::Lite => "lite",
1292 }
1293 }
1294}
1295
1296#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1323pub enum OriginClass {
1324 FirstParty,
1326 #[default]
1329 ThirdParty,
1330}
1331
1332impl OriginClass {
1333 pub fn as_wire(self) -> &'static str {
1337 match self {
1338 Self::FirstParty => "first-party",
1339 Self::ThirdParty => "third-party",
1340 }
1341 }
1342
1343 pub fn is_third_party(self) -> bool {
1346 matches!(self, Self::ThirdParty)
1347 }
1348}
1349
1350fn append_section_format(
1371 obj: &mut serde_json::Map<String, serde_json::Value>,
1372 s: &memstead_schema::SectionDef,
1373) {
1374 if let Some(content) = &s.content {
1375 obj.insert("content".into(), serde_json::json!(content));
1376 obj.insert(
1377 "format_severity".into(),
1378 serde_json::json!(s.format_severity),
1379 );
1380 }
1381 if let Some(pattern) = &s.item_pattern {
1382 obj.insert("item_pattern".into(), serde_json::json!(pattern));
1383 }
1384 if let Some(table) = &s.table {
1385 obj.insert("table".into(), serde_json::json!(table));
1386 }
1387 if let Some(example) = &s.example {
1388 obj.insert("example".into(), serde_json::json!(example));
1389 }
1390}
1391
1392pub fn build_schema_payload(
1393 schema: &Arc<Schema>,
1394 used_by: Vec<String>,
1395 verbosity: SchemaVerbosity,
1396 origin: OriginClass,
1397) -> serde_json::Value {
1398 let manifest = &schema.manifest;
1399 let verbosity = if origin.is_third_party() {
1407 SchemaVerbosity::Lite
1408 } else {
1409 verbosity
1410 };
1411
1412 let relationships: Vec<serde_json::Value> = manifest
1423 .relationships
1424 .definitions
1425 .iter()
1426 .filter(|d| d.name != "_default")
1427 .map(|d| {
1428 let mut o = serde_json::json!({
1449 "name": d.name,
1450 "description": d.description,
1451 "when_to_use": d.when_to_use,
1452 "default_weight": d.default_weight,
1453 "acyclic": d.acyclic,
1454 "per_edge_description": per_edge_description_str(d.per_edge_description),
1455 "manual_authoring": manual_authoring_str(d.manual_authoring),
1456 "allowed_sources": d.source_types,
1457 "allowed_targets": d.target_types,
1458 });
1459 if d.derivation {
1465 o["derivation"] = serde_json::json!(true);
1466 }
1467 o
1468 })
1469 .collect();
1470
1471 let cross_mem_relationships: Vec<serde_json::Value> = manifest
1478 .cross_mem_relationships
1479 .iter()
1480 .map(|entry| {
1481 let definitions: Vec<serde_json::Value> = entry
1482 .definitions
1483 .iter()
1484 .filter(|d| d.name != "_default")
1485 .map(|d| {
1486 serde_json::json!({
1487 "name": d.name,
1488 "description": d.description,
1489 "when_to_use": d.when_to_use,
1490 "default_weight": d.default_weight,
1491 "source_types": d.source_types,
1492 "target_types": d.target_types,
1493 "per_edge_description": per_edge_description_str(d.per_edge_description),
1494 })
1495 })
1496 .collect();
1497 serde_json::json!({
1498 "to_schema": entry.to_schema,
1499 "definitions": definitions,
1500 })
1501 })
1502 .collect();
1503
1504 let types_full: Vec<serde_json::Value> = manifest
1507 .types
1508 .iter()
1509 .filter_map(|name| schema.types.get(name.as_str()).map(|td| (name, td)))
1510 .map(|(_, td)| {
1511 let sections: Vec<serde_json::Value> = td
1512 .sections
1513 .iter()
1514 .map(|s| {
1515 let mut obj = serde_json::json!({
1516 "key": s.key,
1517 "heading": s.heading,
1518 "required": s.required,
1519 "write_rules": s.write_rules,
1520 });
1521 append_section_format(obj.as_object_mut().unwrap(), s);
1527 obj
1528 })
1529 .collect();
1530
1531 let fields: Vec<serde_json::Value> = td
1532 .metadata_fields
1533 .iter()
1534 .map(|f| {
1535 let mut obj = serde_json::json!({
1536 "name": f.key,
1537 "description": f.description,
1538 "required": f.is_required(),
1539 });
1540 if let Some(enum_values) = &f.enum_values {
1541 obj.as_object_mut()
1542 .unwrap()
1543 .insert("enum".into(), serde_json::json!(enum_values));
1544 }
1545 if let Some(default) = &f.default_value {
1552 obj.as_object_mut()
1553 .unwrap()
1554 .insert("default".into(), serde_json::json!(default));
1555 }
1556 obj.as_object_mut().unwrap().insert(
1562 "filterable".into(),
1563 match f.filterable.as_wire_str() {
1564 Some(s) => serde_json::json!(s),
1565 None => serde_json::Value::Null,
1566 },
1567 );
1568 obj
1569 })
1570 .collect();
1571
1572 let required_outgoing: Vec<serde_json::Value> = td
1587 .required_outgoing
1588 .iter()
1589 .map(|block| {
1590 serde_json::json!({
1591 "relationships": block.relationships,
1592 "cardinality": block.cardinality.to_string(),
1593 "severity": block.severity,
1594 })
1595 })
1596 .collect();
1597
1598 let constraints: Vec<serde_json::Value> = td
1607 .constraints
1608 .iter()
1609 .map(|c| match c {
1610 memstead_schema::ConstraintDef::RequiresWhen {
1611 field,
1612 when_field,
1613 when_value,
1614 severity,
1615 } => serde_json::json!({
1616 "kind": "requires_when",
1617 "field": field,
1618 "when_field": when_field,
1619 "when_value": when_value,
1620 "severity": severity,
1621 }),
1622 memstead_schema::ConstraintDef::Unique { fields, severity } => {
1623 serde_json::json!({
1624 "kind": "unique",
1625 "fields": fields,
1626 "severity": severity,
1627 })
1628 }
1629 memstead_schema::ConstraintDef::EnumFromNeighbour {
1630 field,
1631 rel_type,
1632 section,
1633 severity,
1634 } => serde_json::json!({
1635 "kind": "enum_from_neighbour",
1636 "field": field,
1637 "rel_type": rel_type,
1638 "section": section,
1639 "severity": severity,
1640 }),
1641 memstead_schema::ConstraintDef::StatusPropagation {
1642 field,
1643 value,
1644 rel_type,
1645 direction,
1646 severity,
1647 } => serde_json::json!({
1648 "kind": "status_propagation",
1649 "field": field,
1650 "value": value,
1651 "rel_type": rel_type,
1652 "direction": direction,
1653 "severity": severity,
1654 }),
1655 })
1656 .collect();
1657 let mut obj = serde_json::json!({
1658 "name": td.name,
1659 "description": td.description,
1660 "when_to_use": td.when_to_use,
1661 "sections": sections,
1662 "fields": fields,
1663 "writing_guidance": td.write_rules,
1664 "system_context": td.system_message_str(),
1665 "staleness_threshold_days": td.staleness_threshold_days,
1666 "no_self_loop_relationships": td.no_self_loop_relationships,
1667 "required_outgoing": required_outgoing,
1668 "constraints": constraints,
1669 });
1670 if td.leaf {
1674 obj["leaf"] = serde_json::json!(true);
1675 }
1676 if let Some(ex) = &td.exemplar {
1683 let relations: Vec<serde_json::Value> = ex
1684 .relations
1685 .iter()
1686 .map(|r| {
1687 let mut o = serde_json::json!({
1688 "to": r.to,
1689 "type": r.rel_type,
1690 });
1691 if let Some(d) = &r.description {
1692 o["description"] = serde_json::json!(d);
1693 }
1694 o
1695 })
1696 .collect();
1697 obj["exemplar"] = serde_json::json!({
1698 "title": ex.title,
1699 "metadata": ex.metadata,
1700 "sections": ex.sections,
1701 "relations": relations,
1702 });
1703 }
1704 obj
1705 })
1706 .collect();
1707
1708 let mode = match manifest.relationships.mode {
1709 RelationshipMode::Strict => "strict",
1710 RelationshipMode::Open => "open",
1711 };
1712
1713 let full = verbosity == SchemaVerbosity::Full;
1714
1715 let mut payload = serde_json::json!({
1719 "ref": format!("{}@{}", manifest.name, schema.version),
1720 "relationship_mode": mode,
1721 "community": {
1722 "resolution": manifest.community.resolution,
1723 "seed": manifest.community.seed,
1724 },
1725 "used_by": used_by,
1726 "origin": origin.as_wire(),
1732 });
1733 let obj = payload.as_object_mut().unwrap();
1734
1735 if full {
1740 obj.insert(
1741 "description".into(),
1742 serde_json::Value::String(manifest.description.clone()),
1743 );
1744 obj.insert(
1745 "when_to_use".into(),
1746 serde_json::Value::String(manifest.when_to_use.clone()),
1747 );
1748 if let Some(msg) = &manifest.system_message {
1754 obj.insert(
1755 "system_context".into(),
1756 serde_json::Value::String(msg.clone()),
1757 );
1758 }
1759 }
1760
1761 obj.insert(
1768 "no_self_loop_relationships_effect".into(),
1769 serde_json::Value::String(
1770 "Per-type `no_self_loop_relationships` governs exactly one behaviour: \
1771 memstead_relate refuses a self-loop (from == to) on a rel-type the \
1772 source type lists here. It does not propagate impact, imply an \
1773 evidence obligation, or have any other effect (the name says it \
1774 all). To declare real impact propagation, use the \
1775 `status_propagation` constraint (`constraints:` on the type), which \
1776 taints dependents of a terminal status value via a named rel-type \
1777 and direction and surfaces them as health findings."
1778 .to_string(),
1779 ),
1780 );
1781
1782 if let Some(target) = &manifest.alias_target_rel_type {
1791 obj.insert(
1792 "alias_target_rel_type".into(),
1793 serde_json::Value::String(target.clone()),
1794 );
1795 }
1796
1797 if full && let Some(dwg) = &manifest.default_writing_guidance {
1804 let mut block = serde_json::Map::new();
1805 if let Some(avoid) = &dwg.avoid {
1806 block.insert("avoid".into(), serde_json::Value::String(avoid.clone()));
1807 }
1808 if let Some(goal) = &dwg.goal {
1809 block.insert("goal".into(), serde_json::Value::String(goal.clone()));
1810 }
1811 if !block.is_empty() {
1812 obj.insert(
1813 "default_writing_guidance".into(),
1814 serde_json::Value::Object(block),
1815 );
1816 }
1817 }
1818
1819 if full {
1820 obj.insert(
1821 "relationships".into(),
1822 serde_json::Value::Array(relationships),
1823 );
1824 if !cross_mem_relationships.is_empty() {
1828 obj.insert(
1829 "cross_mem_relationships".into(),
1830 serde_json::Value::Array(cross_mem_relationships),
1831 );
1832 }
1833 obj.insert("types".into(), serde_json::Value::Array(types_full));
1834 } else {
1835 let relationships_summary: Vec<serde_json::Value> = relationships
1845 .iter()
1846 .map(|r| {
1847 let mut o = serde_json::json!({
1848 "name": r["name"],
1849 "allowed_sources": r["allowed_sources"],
1850 "allowed_targets": r["allowed_targets"],
1851 "manual_authoring": r["manual_authoring"],
1852 "acyclic": r["acyclic"],
1853 "per_edge_description": r["per_edge_description"],
1854 });
1855 if r.get("derivation") == Some(&serde_json::json!(true)) {
1856 o["derivation"] = serde_json::json!(true);
1857 }
1858 o
1859 })
1860 .collect();
1861 obj.insert(
1862 "relationships_summary".into(),
1863 serde_json::Value::Array(relationships_summary),
1864 );
1865
1866 if !cross_mem_relationships.is_empty() {
1870 let cross_summary: Vec<serde_json::Value> = cross_mem_relationships
1871 .iter()
1872 .map(|e| {
1873 let definitions: Vec<serde_json::Value> = e["definitions"]
1874 .as_array()
1875 .map(|defs| {
1876 defs.iter()
1877 .map(|d| {
1878 serde_json::json!({
1879 "name": d["name"],
1880 "source_types": d["source_types"],
1881 "target_types": d["target_types"],
1882 })
1883 })
1884 .collect()
1885 })
1886 .unwrap_or_default();
1887 serde_json::json!({
1888 "to_schema": e["to_schema"],
1889 "definitions": definitions,
1890 })
1891 })
1892 .collect();
1893 obj.insert(
1894 "cross_mem_relationships_summary".into(),
1895 serde_json::Value::Array(cross_summary),
1896 );
1897 }
1898
1899 let types_summary: Vec<serde_json::Value> = types_full
1913 .iter()
1914 .map(|t| {
1915 let sections: Vec<serde_json::Value> = t["sections"]
1916 .as_array()
1917 .map(|secs| {
1918 secs.iter()
1919 .map(|s| {
1920 let mut o = serde_json::Map::new();
1921 o.insert("key".into(), s["key"].clone());
1922 o.insert("required".into(), s["required"].clone());
1923 for k in [
1927 "content",
1928 "item_pattern",
1929 "table",
1930 "example",
1931 "format_severity",
1932 ] {
1933 if let Some(v) = s.get(k) {
1934 o.insert(k.into(), v.clone());
1935 }
1936 }
1937 serde_json::Value::Object(o)
1938 })
1939 .collect()
1940 })
1941 .unwrap_or_default();
1942 let fields: Vec<serde_json::Value> = t["fields"]
1943 .as_array()
1944 .map(|fs| {
1945 fs.iter()
1946 .map(|f| {
1947 let mut o = serde_json::Map::new();
1948 o.insert("name".into(), f["name"].clone());
1949 o.insert("required".into(), f["required"].clone());
1950 if let Some(e) = f.get("enum") {
1951 o.insert("enum".into(), e.clone());
1952 }
1953 if let Some(d) = f.get("default") {
1954 o.insert("default".into(), d.clone());
1955 }
1956 serde_json::Value::Object(o)
1957 })
1958 .collect()
1959 })
1960 .unwrap_or_default();
1961 let mut o = serde_json::json!({
1962 "name": t["name"],
1963 "sections": sections,
1964 "fields": fields,
1965 "no_self_loop_relationships": t["no_self_loop_relationships"],
1966 "required_outgoing": t["required_outgoing"],
1967 "constraints": t["constraints"],
1968 });
1969 if t.get("leaf") == Some(&serde_json::json!(true)) {
1972 o["leaf"] = serde_json::json!(true);
1973 }
1974 o
1975 })
1976 .collect();
1977 obj.insert(
1978 "types_summary".into(),
1979 serde_json::Value::Array(types_summary),
1980 );
1981 }
1982
1983 payload
1984}
1985
1986fn describe_metadata_field(field: &memstead_schema::MetadataFieldDef) -> String {
1988 let type_str = match field.field_type {
1989 FieldType::String => "String",
1990 FieldType::Number => "Number",
1991 FieldType::Date => "Date",
1992 FieldType::Boolean => "Boolean",
1993 };
1994
1995 let mut flags: Vec<&str> = Vec::new();
1996 if !field.is_required() {
1997 flags.push("optional");
1998 } else {
1999 flags.push("required");
2000 }
2001 if field.init_timestamp {
2002 flags.push("auto-init");
2003 }
2004 if field.auto_timestamp {
2005 flags.push("auto-update");
2006 }
2007 match field.serialization {
2008 Serialization::CsvArray => flags.push("csv array"),
2009 Serialization::OmitWhenFalsy => flags.push("omit when falsy"),
2010 Serialization::Default => {}
2011 }
2012
2013 let mut extras: Vec<String> = Vec::new();
2014 if let Some(values) = &field.enum_values {
2015 extras.push(format!("enum: {}", values.join(", ")));
2016 }
2017 if let Some(default) = &field.default_value {
2018 extras.push(format!("default: {default}"));
2019 }
2020 let filterable_str = match field.filterable {
2021 Filterable::None => None,
2022 Filterable::Equality => Some("filterable: equality"),
2023 Filterable::Range => Some("filterable: range"),
2024 };
2025 if let Some(f) = filterable_str {
2026 extras.push(f.to_string());
2027 }
2028
2029 let extras_str = if extras.is_empty() {
2030 String::new()
2031 } else {
2032 format!(" — {}", extras.join(" — "))
2033 };
2034
2035 format!(
2036 "**{key}**: {type_str} ({flags}){extras_str}",
2037 key = field.key,
2038 flags = flags.join(", "),
2039 )
2040}
2041
2042#[cfg(test)]
2043mod tests {
2044 use super::*;
2045 use crate::{Entity, EntityId, ListResult, SearchResult};
2046 use indexmap::IndexMap;
2047 use std::collections::HashMap;
2048
2049 fn make_hit(id: &str, title: &str, entity_type: &str, sections: &[(&str, &str)]) -> SearchHit {
2050 SearchHit {
2051 id: EntityId(id.to_string()),
2052 last_modified: None,
2053 title: title.to_string(),
2054 mem: id.split("--").next().unwrap_or("").to_string(),
2055 entity_type: entity_type.to_string(),
2056 stub: false,
2057 score: 1.0,
2058 tokens: 10,
2059 snippet: None,
2060 sections: sections
2061 .iter()
2062 .map(|(k, v)| (k.to_string(), v.to_string()))
2063 .collect(),
2064 score_breakdown: None,
2065 matched_terms: None,
2066 expansion: None,
2067 summary: None,
2070 }
2071 }
2072
2073 fn search_result(hits: Vec<SearchHit>) -> SearchResult {
2074 let returned = hits.len();
2075 let total_tokens = hits.iter().map(|h| h.tokens).sum();
2076 SearchResult {
2077 total: returned,
2078 returned,
2079 offset: 0,
2080 total_tokens,
2081 hits,
2082 facets: None,
2083 warnings: vec![],
2084 }
2085 }
2086
2087 fn list_result(hits: Vec<SearchHit>) -> ListResult {
2088 let returned = hits.len();
2089 ListResult {
2090 total: returned,
2091 returned,
2092 offset: 0,
2093 total_tokens: hits.iter().map(|h| h.tokens).sum(),
2094 hits,
2095 warnings: vec![],
2096 }
2097 }
2098
2099 fn test_entity() -> Entity {
2100 Entity {
2101 id: EntityId("specs--test-entity".to_string()),
2102 title: "Test Entity".to_string(),
2103 entity_type: "spec".to_string(),
2104 mem: "specs".to_string(),
2105 file_path: "test-entity.md".to_string(),
2106 metadata: IndexMap::new(),
2107 sections: IndexMap::from([
2108 ("identity".to_string(), "A test entity for unit tests.".to_string()),
2109 ("purpose".to_string(), "Validates render logic.".to_string()),
2110 ("specifies".to_string(), "Long section content that adds significant token weight to the full entity estimate.".to_string()),
2111 ]),
2112 relationships: vec![],
2113 content_hash: "abc123".to_string(),
2114 stub: false,
2115 stub_kind: None,
2116 heading_spans: std::collections::HashMap::new(),
2117 raw_section_headings: Vec::new(),
2118 }
2119 }
2120
2121 #[test]
2122 fn section_key_to_heading_basic() {
2123 assert_eq!(section_key_to_heading("identity"), "Identity");
2124 assert_eq!(section_key_to_heading("current_state"), "Current state");
2125 }
2126
2127 #[test]
2128 fn render_uses_schema_declared_heading_for_non_trivial_casing() {
2129 let mut sections: IndexMap<String, String> = IndexMap::new();
2135 sections.insert("claim_a".to_string(), "Body A.".to_string());
2136 sections.insert("claim_b".to_string(), "Body B.".to_string());
2137
2138 let entity = Entity {
2139 id: EntityId("ingest--example".to_string()),
2140 title: "Example".to_string(),
2141 entity_type: "inconsistency".to_string(),
2142 mem: "ingest".to_string(),
2143 file_path: "example.md".to_string(),
2144 metadata: IndexMap::new(),
2145 sections,
2146 relationships: vec![],
2147 content_hash: "h".to_string(),
2148 stub: false,
2149 stub_kind: None,
2150 heading_spans: std::collections::HashMap::new(),
2151 raw_section_headings: Vec::new(),
2152 };
2153
2154 let md = render_entity_markdown(&entity, None);
2155 assert!(
2156 md.contains("## Claim A"),
2157 "expected schema-declared `## Claim A` heading; got:\n{md}"
2158 );
2159 assert!(
2160 md.contains("## Claim B"),
2161 "expected schema-declared `## Claim B` heading; got:\n{md}"
2162 );
2163 assert!(
2165 !md.contains("## Claim a"),
2166 "renderer must not fall back to key-derivation when the \
2167 schema declares a heading; got:\n{md}"
2168 );
2169 }
2170
2171 #[test]
2172 fn render_falls_back_to_key_derivation_for_unknown_types() {
2173 let mut sections: IndexMap<String, String> = IndexMap::new();
2177 sections.insert("identity".to_string(), "body".to_string());
2178
2179 let entity = Entity {
2180 id: EntityId("custom--example".to_string()),
2181 title: "Example".to_string(),
2182 entity_type: "not-a-builtin-type".to_string(),
2183 mem: "custom".to_string(),
2184 file_path: "example.md".to_string(),
2185 metadata: IndexMap::new(),
2186 sections,
2187 relationships: vec![],
2188 content_hash: "h".to_string(),
2189 stub: false,
2190 stub_kind: None,
2191 heading_spans: std::collections::HashMap::new(),
2192 raw_section_headings: Vec::new(),
2193 };
2194
2195 let md = render_entity_markdown(&entity, None);
2196 assert!(
2197 md.contains("## Identity"),
2198 "fallback derivation must produce `## Identity`; got:\n{md}"
2199 );
2200 }
2201
2202 #[test]
2209 fn render_entity_sections_follow_indexmap_insertion_order() {
2210 let mut sections: IndexMap<String, String> = IndexMap::new();
2211 sections.insert("specifies".to_string(), "S content.".to_string());
2212 sections.insert("purpose".to_string(), "P content.".to_string());
2213 sections.insert("identity".to_string(), "I content.".to_string());
2214
2215 let entity = Entity {
2216 id: EntityId("specs--order-test".to_string()),
2217 title: "Order Test".to_string(),
2218 entity_type: "spec".to_string(),
2219 mem: "specs".to_string(),
2220 file_path: "order-test.md".to_string(),
2221 metadata: IndexMap::new(),
2222 sections,
2223 relationships: vec![],
2224 content_hash: "abc123".to_string(),
2225 stub: false,
2226 stub_kind: None,
2227 heading_spans: std::collections::HashMap::new(),
2228 raw_section_headings: Vec::new(),
2229 };
2230
2231 let md = render_entity_markdown(&entity, None);
2232 let specifies_pos = md.find("## Specifies").expect("## Specifies must appear");
2233 let purpose_pos = md.find("## Purpose").expect("## Purpose must appear");
2234 let identity_pos = md.find("## Identity").expect("## Identity must appear");
2235
2236 assert!(
2237 specifies_pos < purpose_pos,
2238 "Specifies (inserted first) must render before Purpose; got:\n{md}"
2239 );
2240 assert!(
2241 purpose_pos < identity_pos,
2242 "Purpose (inserted second) must render before Identity; got:\n{md}"
2243 );
2244 }
2245
2246 #[test]
2252 fn tokens_reflect_filtered_output() {
2253 let entity = test_entity();
2254
2255 let full = render_entity_markdown(&entity, None);
2257 assert!(full.contains("_tokens:"), "should have _tokens");
2258 assert!(
2259 !full.contains("_tokens_unfiltered_body:"),
2260 "should NOT have _tokens_unfiltered_body when unfiltered"
2261 );
2262 assert!(
2263 !full.contains("_tokens_full:"),
2264 "old _tokens_full name must not survive — rename is one-way"
2265 );
2266
2267 let filtered = render_entity_markdown(&entity, Some(&["identity".to_string()]));
2269 assert!(filtered.contains("_tokens:"), "should have _tokens");
2270 assert!(
2271 filtered.contains("_tokens_unfiltered_body:"),
2272 "should have _tokens_unfiltered_body when filtered"
2273 );
2274 assert!(
2275 !filtered.contains("_tokens_full:"),
2276 "old _tokens_full name must not survive — rename is one-way"
2277 );
2278
2279 let full_tokens: usize = full
2281 .lines()
2282 .find(|l| l.starts_with("_tokens:"))
2283 .unwrap()
2284 .trim_start_matches("_tokens: ")
2285 .parse()
2286 .unwrap();
2287 let filtered_tokens: usize = filtered
2288 .lines()
2289 .find(|l| l.starts_with("_tokens:"))
2290 .unwrap()
2291 .trim_start_matches("_tokens: ")
2292 .parse()
2293 .unwrap();
2294 let tokens_unfiltered_body: usize = filtered
2295 .lines()
2296 .find(|l| l.starts_with("_tokens_unfiltered_body:"))
2297 .unwrap()
2298 .trim_start_matches("_tokens_unfiltered_body: ")
2299 .parse()
2300 .unwrap();
2301
2302 assert!(
2303 filtered_tokens < full_tokens,
2304 "filtered _tokens ({filtered_tokens}) should be less than full _tokens ({full_tokens})"
2305 );
2306 assert!(
2307 tokens_unfiltered_body >= full_tokens,
2308 "_tokens_unfiltered_body ({tokens_unfiltered_body}) should be >= full render _tokens ({full_tokens})"
2309 );
2310 }
2311
2312 #[test]
2317 fn render_search_uses_first_required_section_for_spec() {
2318 let hit = make_hit(
2319 "specs--demo",
2320 "Demo Spec",
2321 "spec",
2322 &[
2323 ("identity", "A demo spec."),
2324 ("purpose", "Verifies rendering."),
2325 ],
2326 );
2327 let out = render_search_markdown(&search_result(vec![hit]), 0);
2328 assert!(
2329 out.contains("**Identity**: A demo spec."),
2330 "expected Identity line for spec hit, got:\n{out}"
2331 );
2332 }
2333
2334 #[test]
2335 fn render_search_uses_first_required_section_for_memo() {
2336 let hit = make_hit(
2337 "memos--d1",
2338 "Memo One",
2339 "memo",
2340 &[("claim", "Some claim."), ("context", "Some context.")],
2341 );
2342 let out = render_search_markdown(&search_result(vec![hit]), 0);
2343 assert!(
2344 out.contains("**Claim**: Some claim."),
2345 "expected Claim line for memo hit, got:\n{out}"
2346 );
2347 assert!(
2348 !out.contains("**Identity**"),
2349 "memo hit must not render Identity label"
2350 );
2351 assert!(
2352 !out.contains("**Purpose**"),
2353 "memo hit must not render Purpose label"
2354 );
2355 }
2356
2357 #[test]
2358 fn render_search_uses_first_required_section_for_concept() {
2359 let hit = make_hit(
2360 "concepts--thing",
2361 "Thing",
2362 "concept",
2363 &[("definition", "A thing."), ("explanation", "Details.")],
2364 );
2365 let out = render_search_markdown(&search_result(vec![hit]), 0);
2366 assert!(
2367 out.contains("**Definition**: A thing."),
2368 "expected Definition line for concept hit, got:\n{out}"
2369 );
2370 }
2371
2372 #[test]
2373 fn render_search_missing_summary_section_shows_dash() {
2374 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2376 let out = render_search_markdown(&search_result(vec![hit]), 0);
2377 assert!(
2378 out.contains("**Claim**: —"),
2379 "expected Claim dash fallback, got:\n{out}"
2380 );
2381 }
2382
2383 #[test]
2384 fn render_search_mixes_schemas_in_one_result() {
2385 let spec_hit = make_hit(
2386 "specs--s1",
2387 "Spec One",
2388 "spec",
2389 &[("identity", "Spec body.")],
2390 );
2391 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2392 let out = render_search_markdown(&search_result(vec![spec_hit, memo_hit]), 0);
2393 assert!(
2394 out.contains("**Identity**: Spec body."),
2395 "spec hit should still render Identity, got:\n{out}"
2396 );
2397 assert!(
2398 out.contains("**Claim**: Memo claim."),
2399 "memo hit should render Claim in the same output, got:\n{out}"
2400 );
2401 }
2402
2403 #[test]
2404 fn render_search_unknown_schema_shows_summary_dash() {
2405 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2406 let out = render_search_markdown(&search_result(vec![hit]), 0);
2407 assert!(
2408 out.contains("**Summary**: —"),
2409 "unknown schema should render Summary dash, got:\n{out}"
2410 );
2411 }
2412
2413 #[test]
2414 fn summary_pair_falls_back_when_schema_has_no_required_sections() {
2415 use memstead_schema::{SectionDef, TypeDefinition};
2416
2417 let schema = TypeDefinition {
2418 name: "spec".to_string(),
2419 description: "test".to_string(),
2420 when_to_use: "test".to_string(),
2421 boundaries: vec![],
2422 exemplar: None,
2423 legacy_examples: None,
2424 system_message: None,
2425 sections: vec![SectionDef {
2426 key: "note".to_string(),
2427 heading: "Note".to_string(),
2428 required: false,
2429 search_weight: 1.0,
2430 catch_all: false,
2431 write_rules: vec![],
2432 description: None,
2433 content: None,
2434 item_pattern: None,
2435 table: None,
2436 example: None,
2437 format_severity: memstead_schema::ConstraintSeverity::Block,
2438 compiled_content: None,
2439 format_problems: Vec::new(),
2440 }],
2441 metadata_fields: vec![],
2442 title_weight: 1.0,
2443 text_fields: vec![],
2444 hierarchy_relationship: "PART_OF".to_string(),
2445 edge_weight_overrides: indexmap::IndexMap::new(),
2446 edge_weights: indexmap::IndexMap::new(),
2447 no_self_loop_relationships: vec![],
2448 legacy_propagating_relationships: None,
2449 due: None,
2450 leaf: false,
2451 updatable_fields: vec![],
2452 health_required_fields: vec![],
2453 staleness_threshold_days: 90,
2454 write_rules: vec![],
2455 required_outgoing: vec![],
2456 constraints: vec![],
2457 declared_metadata_keys: vec![],
2458 };
2459
2460 let mut sections = HashMap::new();
2461 sections.insert("note".to_string(), "a note".to_string());
2462 assert_eq!(
2463 summary_pair(Some(&schema), §ions),
2464 ("Note".to_string(), "a note".to_string()),
2465 );
2466
2467 assert_eq!(
2468 summary_pair(Some(&schema), &HashMap::new()),
2469 ("Note".to_string(), "—".to_string()),
2470 );
2471 }
2472
2473 #[test]
2478 fn render_list_uses_first_required_section_for_spec() {
2479 let hit = make_hit(
2480 "specs--demo",
2481 "Demo Spec",
2482 "spec",
2483 &[
2484 ("identity", "A demo spec."),
2485 ("purpose", "Verifies rendering."),
2486 ],
2487 );
2488 let out = render_list_markdown(&list_result(vec![hit]));
2489 assert!(
2490 out.contains("**Identity**: A demo spec."),
2491 "expected Identity line for spec hit, got:\n{out}"
2492 );
2493 }
2494
2495 #[test]
2496 fn render_list_uses_first_required_section_for_memo() {
2497 let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
2498 let out = render_list_markdown(&list_result(vec![hit]));
2499 assert!(
2500 out.contains("**Claim**: Some claim."),
2501 "expected Claim line for memo hit, got:\n{out}"
2502 );
2503 assert!(
2504 !out.contains("**Identity**"),
2505 "memo hit must not render Identity label in list output"
2506 );
2507 }
2508
2509 #[test]
2510 fn render_list_uses_first_required_section_for_concept() {
2511 let hit = make_hit(
2512 "concepts--thing",
2513 "Thing",
2514 "concept",
2515 &[("definition", "A thing.")],
2516 );
2517 let out = render_list_markdown(&list_result(vec![hit]));
2518 assert!(
2519 out.contains("**Definition**: A thing."),
2520 "expected Definition line for concept hit, got:\n{out}"
2521 );
2522 }
2523
2524 #[test]
2525 fn render_list_missing_summary_section_shows_dash() {
2526 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2527 let out = render_list_markdown(&list_result(vec![hit]));
2528 assert!(
2529 out.contains("**Claim**: —"),
2530 "expected Claim dash fallback in list output, got:\n{out}"
2531 );
2532 }
2533
2534 #[test]
2535 fn render_list_mixes_schemas_in_one_result() {
2536 let spec_hit = make_hit(
2537 "specs--s1",
2538 "Spec One",
2539 "spec",
2540 &[("identity", "Spec body.")],
2541 );
2542 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2543 let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
2544 assert!(
2545 out.contains("**Identity**: Spec body."),
2546 "spec hit should still render Identity in list output, got:\n{out}"
2547 );
2548 assert!(
2549 out.contains("**Claim**: Memo claim."),
2550 "memo hit should render Claim in list output, got:\n{out}"
2551 );
2552 }
2553
2554 #[test]
2555 fn render_list_unknown_schema_shows_summary_dash() {
2556 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2557 let out = render_list_markdown(&list_result(vec![hit]));
2558 assert!(
2559 out.contains("**Summary**: —"),
2560 "unknown schema should render Summary dash in list output, got:\n{out}"
2561 );
2562 }
2563
2564 #[test]
2569 fn summary_pair_for_spec_returns_identity() {
2570 let schema = type_by_name("spec");
2571 let mut sections = HashMap::new();
2572 sections.insert("identity".to_string(), "A demo spec.".to_string());
2573 assert_eq!(
2574 summary_pair(schema.as_deref(), §ions),
2575 ("Identity".to_string(), "A demo spec.".to_string()),
2576 );
2577 }
2578
2579 #[test]
2580 fn summary_pair_for_memo_returns_claim() {
2581 let schema = type_by_name("memo");
2582 let mut sections = HashMap::new();
2583 sections.insert("claim".to_string(), "Memos matter.".to_string());
2584 assert_eq!(
2585 summary_pair(schema.as_deref(), §ions),
2586 ("Claim".to_string(), "Memos matter.".to_string()),
2587 );
2588 }
2589
2590 #[test]
2591 fn summary_pair_missing_section_returns_dash() {
2592 let schema = type_by_name("memo");
2593 assert_eq!(
2594 summary_pair(schema.as_deref(), &HashMap::new()),
2595 ("Claim".to_string(), "—".to_string()),
2596 );
2597 }
2598
2599 #[test]
2600 fn summary_pair_unknown_schema_returns_summary_dash() {
2601 assert_eq!(
2602 summary_pair(None, &HashMap::new()),
2603 ("Summary".to_string(), "—".to_string()),
2604 );
2605 }
2606
2607 #[test]
2612 fn envelope_serializes_summary_fields() {
2613 let hit = make_hit(
2614 "memos--d1",
2615 "Memo One",
2616 "memo",
2617 &[("claim", "Memos matter.")],
2618 );
2619 let result = search_result(vec![hit]);
2620 let envelope = build_search_envelope(&result, 0);
2621 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2622
2623 assert_eq!(value["_total"], 1);
2627 assert_eq!(value["_returned"], 1);
2628 assert_eq!(value["_offset"], 0);
2629 assert!(
2631 value.get("warnings").is_none(),
2632 "empty warnings must be elided, got: {value}"
2633 );
2634
2635 let hit0 = &value["hits"][0];
2636 assert_eq!(hit0["summary_heading"], "Claim");
2637 assert_eq!(hit0["summary_value"], "Memos matter.");
2638 assert_eq!(hit0["id"], "memos--d1");
2640 assert_eq!(hit0["title"], "Memo One");
2641 assert_eq!(hit0["entity_type"], "memo");
2642 assert_eq!(hit0["mem"], "memos");
2643 assert_eq!(hit0["stub"], false);
2644 assert_eq!(hit0["tokens"], 10);
2645 assert!(hit0["sections"].is_object());
2646 }
2647
2648 #[test]
2649 fn envelope_roundtrips_through_structured_content() {
2650 let spec_hit = make_hit(
2653 "specs--s1",
2654 "Spec One",
2655 "spec",
2656 &[("identity", "Spec body.")],
2657 );
2658 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2659 let result = search_result(vec![spec_hit, memo_hit]);
2660 let envelope = build_search_envelope(&result, 0);
2661 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2662
2663 let hits = value["hits"].as_array().expect("hits must be array");
2664 assert_eq!(hits.len(), 2);
2665 assert_eq!(hits[0]["summary_heading"], "Identity");
2666 assert_eq!(hits[0]["summary_value"], "Spec body.");
2667 assert_eq!(hits[1]["summary_heading"], "Claim");
2668 assert_eq!(hits[1]["summary_value"], "Memo claim.");
2669 }
2670
2671 #[test]
2672 fn list_envelope_includes_total_tokens() {
2673 let hit = make_hit(
2674 "concepts--c1",
2675 "Thing",
2676 "concept",
2677 &[("definition", "A thing.")],
2678 );
2679 let result = list_result(vec![hit]);
2680 let envelope = build_list_envelope(&result);
2681 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2682
2683 assert_eq!(value["_total"], 1);
2685 assert_eq!(value["_total_tokens"], 10);
2686 assert!(value.get("total").is_none(), "unprefixed keys retired");
2687 assert_eq!(value["hits"][0]["summary_heading"], "Definition");
2688 assert_eq!(value["hits"][0]["summary_value"], "A thing.");
2689 }
2690
2691 #[test]
2692 fn envelope_emits_warnings_when_present() {
2693 let mut result = search_result(vec![]);
2694 result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
2697 field: "foo".to_string(),
2698 }];
2699 let envelope = build_search_envelope(&result, 0);
2700 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2701 assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
2702 assert_eq!(value["warnings"][0]["details"]["field"], "foo");
2703 assert!(
2704 value["warnings"][0]["message"]
2705 .as_str()
2706 .is_some_and(|m| m.contains("not filterable"))
2707 );
2708 }
2709
2710 fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
2715 TermMatch {
2716 field: field.to_string(),
2717 snippet: snippet.to_string(),
2718 heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
2719 }
2720 }
2721
2722 fn sample_facets() -> Facets {
2723 use crate::ops::SubsectionFacet;
2724 Facets {
2725 by_type: HashMap::from([
2726 ("spec".to_string(), 7),
2727 ("memo".to_string(), 3),
2728 ("decision".to_string(), 2),
2729 ]),
2730 by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
2731 by_level: HashMap::from([("high".to_string(), 4)]),
2732 by_status: HashMap::from([("active".to_string(), 6)]),
2733 by_confidence: HashMap::from([("medium".to_string(), 3)]),
2734 by_subsection: vec![
2735 SubsectionFacet {
2736 path: vec!["specifies".to_string(), "Response Shapes".to_string()],
2737 count: 4,
2738 },
2739 SubsectionFacet {
2740 path: vec!["purpose".to_string(), "Rationale".to_string()],
2741 count: 2,
2742 },
2743 ],
2744 by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
2745 }
2746 }
2747
2748 #[test]
2749 fn render_search_emits_matched_terms_line() {
2750 let mut hit = make_hit(
2751 "specs--e1",
2752 "Entity One",
2753 "spec",
2754 &[("identity", "Body text.")],
2755 );
2756 hit.matched_terms = Some(HashMap::from([
2757 (
2758 "entity".to_string(),
2759 vec![
2760 tm("title", "...entity...", None),
2761 tm("purpose", "...entity...", None),
2762 tm("purpose", "...entity two...", None),
2763 ],
2764 ),
2765 ("one".to_string(), vec![tm("title", "...one...", None)]),
2766 ]));
2767 let out = render_search_markdown(&search_result(vec![hit]), 0);
2768 assert!(
2769 out.contains("**Matched terms:**"),
2770 "missing Matched terms line; got:\n{out}"
2771 );
2772 assert!(
2773 out.contains("`entity` (purpose×2, title×1)"),
2774 "entity term grouping wrong; got:\n{out}"
2775 );
2776 assert!(
2777 out.contains("`one` (title×1)"),
2778 "one term grouping wrong; got:\n{out}"
2779 );
2780 }
2781
2782 #[test]
2783 fn render_search_emits_score_breakdown_line() {
2784 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2785 hit.score_breakdown = Some(ScoreBreakdown {
2786 bm25: 2.5,
2787 title_boost: 2.0,
2788 field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
2789 expansion_decay: Some(0.5),
2790 });
2791 let out = render_search_markdown(&search_result(vec![hit]), 0);
2792 assert!(
2793 out.contains(
2794 "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
2795 ),
2796 "score breakdown line wrong; got:\n{out}"
2797 );
2798 }
2799
2800 #[test]
2801 fn render_search_omits_expansion_decay_when_none() {
2802 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2803 hit.score_breakdown = Some(ScoreBreakdown {
2804 bm25: 1.5,
2805 title_boost: 1.0,
2806 field_weights: HashMap::new(),
2807 expansion_decay: None,
2808 });
2809 let out = render_search_markdown(&search_result(vec![hit]), 0);
2810 assert!(
2811 out.contains("**Score:** bm25 1.5 + title 1.0"),
2812 "base score wrong; got:\n{out}"
2813 );
2814 assert!(
2815 !out.contains("expansion_decay"),
2816 "expansion_decay must be absent when None; got:\n{out}"
2817 );
2818 }
2819
2820 #[test]
2821 fn render_search_emits_heading_path_line() {
2822 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2823 hit.matched_terms = Some(HashMap::from([(
2824 "x".to_string(),
2825 vec![
2826 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
2827 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
2829 ],
2830 )]));
2831 let out = render_search_markdown(&search_result(vec![hit]), 0);
2832 assert!(
2833 out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
2834 "heading path line wrong; got:\n{out}"
2835 );
2836 }
2837
2838 #[test]
2839 fn render_search_emits_expansion_line() {
2840 let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
2841 hit.expansion = Some(ExpansionInfo {
2842 of: EntityId("specs--seed".to_string()),
2843 via_edge: "refines".to_string(),
2844 via_direction: crate::graph::query::TraversalDirection::Out,
2845 depth: 1,
2846 });
2847 let out = render_search_markdown(&search_result(vec![hit]), 0);
2848 assert!(
2849 out.contains("**Expansion:** from `specs--seed` via `refines` [out] (depth 1)"),
2850 "expansion line reports the traversal direction beside the label; got:\n{out}"
2851 );
2852 }
2853
2854 #[test]
2855 fn render_search_emits_facets_block() {
2856 let mut result = search_result(vec![]);
2857 result.facets = Some(sample_facets());
2858 let out = render_search_markdown(&result, 0);
2859 assert!(
2860 out.contains("## Facets"),
2861 "facets header missing; got:\n{out}"
2862 );
2863 assert!(
2864 out.contains("- **by_type:** spec=7, memo=3, decision=2"),
2865 "by_type bucket wrong; got:\n{out}"
2866 );
2867 assert!(
2868 out.contains("- **by_mem:** specs=10, memos=2"),
2869 "by_mem bucket wrong; got:\n{out}"
2870 );
2871 assert!(
2872 out.contains("- **by_level:** high=4"),
2873 "by_level bucket wrong; got:\n{out}"
2874 );
2875 assert!(
2876 out.contains("- **by_status:** active=6"),
2877 "by_status bucket wrong; got:\n{out}"
2878 );
2879 assert!(
2880 out.contains("- **by_confidence:** medium=3"),
2881 "by_confidence bucket wrong; got:\n{out}"
2882 );
2883 assert!(
2884 out.contains("- **by_expansion:** primary=8, expanded=4"),
2885 "by_expansion bucket wrong; got:\n{out}"
2886 );
2887 assert!(
2888 out.contains("- **by_subsection:**"),
2889 "by_subsection header missing; got:\n{out}"
2890 );
2891 assert!(
2892 out.contains("`specifies › Response Shapes`: 4"),
2893 "subsection facet wrong; got:\n{out}"
2894 );
2895 }
2896
2897 #[test]
2898 fn render_search_omits_facets_block_when_all_empty() {
2899 let mut result = search_result(vec![]);
2900 result.facets = Some(Facets::default());
2901 let out = render_search_markdown(&result, 0);
2902 assert!(
2903 !out.contains("## Facets"),
2904 "empty facets must not emit header; got:\n{out}"
2905 );
2906 }
2907
2908 #[test]
2912 fn search_markdown_covers_every_sidecar_field() {
2913 let mut hit = make_hit(
2914 "specs--e1",
2915 "Entity One",
2916 "spec",
2917 &[("identity", "Body text.")],
2918 );
2919 hit.matched_terms = Some(HashMap::from([(
2920 "entity".to_string(),
2921 vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
2922 )]));
2923 hit.score_breakdown = Some(ScoreBreakdown {
2924 bm25: 1.5,
2925 title_boost: 1.0,
2926 field_weights: HashMap::from([("body".to_string(), 0.4)]),
2927 expansion_decay: Some(0.5),
2928 });
2929 hit.expansion = Some(ExpansionInfo {
2930 of: EntityId("specs--seed".to_string()),
2931 via_edge: "refines".to_string(),
2932 via_direction: crate::graph::query::TraversalDirection::Out,
2933 depth: 2,
2934 });
2935
2936 let mut result = search_result(vec![hit]);
2937 result.facets = Some(sample_facets());
2938
2939 let out = render_search_markdown(&result, 0);
2940 for marker in [
2941 "## Facets",
2942 "- **by_type:**",
2943 "- **by_mem:**",
2944 "- **by_level:**",
2945 "- **by_status:**",
2946 "- **by_confidence:**",
2947 "- **by_expansion:**",
2948 "- **by_subsection:**",
2949 "**Matched terms:**",
2950 "**Score:**",
2951 "**Heading path:**",
2952 "**Expansion:**",
2953 ] {
2954 assert!(
2955 out.contains(marker),
2956 "lockstep marker `{marker}` missing from search markdown; \
2957 update render_search_markdown when adding sidecar fields. got:\n{out}"
2958 );
2959 }
2960 }
2961
2962 #[test]
2969 fn build_entity_envelope_source_field_reads_edge_source() {
2970 let mut entity = test_entity();
2971 let body_link_target = EntityId("specs--body-link-target".to_string());
2972 let explicit_target = EntityId("specs--explicit-target".to_string());
2973 entity.relationships = vec![
2974 crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
2975 crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
2976 ];
2977
2978 let edges = vec![
2979 crate::store::Edge {
2980 rel_type: "REFERENCES".to_string(),
2981 target: body_link_target.clone(),
2982 source: crate::store::EdgeSource::BodyLink,
2983 },
2984 crate::store::Edge {
2985 rel_type: "USES".to_string(),
2986 target: explicit_target.clone(),
2987 source: crate::store::EdgeSource::Explicit,
2988 },
2989 ];
2990
2991 let env = build_entity_envelope(
2992 &entity,
2993 0,
2994 None,
2995 None,
2996 None,
2997 OriginClass::FirstParty,
2998 &edges,
2999 None,
3000 );
3001 let relationships = env["relationships"].as_array().expect("array");
3002 let refs = relationships
3003 .iter()
3004 .find(|r| r["rel_type"] == "REFERENCES")
3005 .expect("REFERENCES present");
3006 assert_eq!(
3007 refs["source"], "body_link",
3008 "alias-synthesised edge must label body_link"
3009 );
3010 let uses = relationships
3011 .iter()
3012 .find(|r| r["rel_type"] == "USES")
3013 .expect("USES present");
3014 assert_eq!(
3015 uses["source"], "explicit",
3016 "explicit-authored edge must label explicit"
3017 );
3018 }
3019
3020 #[test]
3027 fn build_entity_envelope_carries_origin_direction_and_incoming() {
3028 let mut entity = test_entity();
3029 let out_target = EntityId("specs--downstream".to_string());
3030 entity.relationships = vec![crate::entity::Relationship::new(
3031 "USES".to_string(),
3032 out_target.clone(),
3033 )];
3034 let edges = vec![crate::store::Edge {
3035 rel_type: "USES".to_string(),
3036 target: out_target,
3037 source: crate::store::EdgeSource::Explicit,
3038 }];
3039 let incoming = vec![crate::store::InEdge {
3040 rel_type: "MANAGES".to_string(),
3041 from: EntityId("specs--upstream".to_string()),
3042 source: crate::store::EdgeSource::Explicit,
3043 }];
3044
3045 let env = build_entity_envelope(
3047 &entity,
3048 0,
3049 None,
3050 None,
3051 None,
3052 OriginClass::ThirdParty,
3053 &edges,
3054 None,
3055 );
3056 assert_eq!(env["origin"], "third-party", "origin is envelope-level");
3057 let rels = env["relationships"].as_array().expect("array");
3058 assert_eq!(rels.len(), 1);
3059 assert_eq!(rels[0]["direction"], "out");
3060
3061 let env = build_entity_envelope(
3064 &entity,
3065 0,
3066 None,
3067 None,
3068 None,
3069 OriginClass::FirstParty,
3070 &edges,
3071 Some(&incoming),
3072 );
3073 assert_eq!(env["origin"], "first-party");
3074 let rels = env["relationships"].as_array().expect("array");
3075 assert_eq!(rels.len(), 2);
3076 let inc = rels
3077 .iter()
3078 .find(|r| r["direction"] == "in")
3079 .expect("incoming entry present");
3080 assert_eq!(inc["rel_type"], "MANAGES");
3081 assert_eq!(inc["from"], "specs--upstream");
3082 assert!(
3083 inc.get("target").is_none(),
3084 "incoming carries from, not target"
3085 );
3086 }
3087
3088 #[test]
3093 fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
3094 let mut entity = test_entity();
3095 let target = EntityId("specs--unmapped".to_string());
3096 entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
3097 let edges: Vec<crate::store::Edge> = Vec::new();
3098 let env = build_entity_envelope(
3099 &entity,
3100 0,
3101 None,
3102 None,
3103 None,
3104 OriginClass::FirstParty,
3105 &edges,
3106 None,
3107 );
3108 let relationships = env["relationships"].as_array().expect("array");
3109 assert_eq!(relationships[0]["source"], "explicit");
3110 }
3111
3112 #[test]
3118 fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
3119 use crate::entity::MetadataValue;
3120 let mut entity = test_entity();
3121 entity.entity_type = "contract".to_string();
3122 entity.metadata = IndexMap::from([
3124 ("level".to_string(), MetadataValue::String("M0".to_string())),
3125 (
3126 "stability".to_string(),
3127 MetadataValue::String("stable".to_string()),
3128 ),
3129 (
3130 "created_date".to_string(),
3131 MetadataValue::String("2026-01-01".to_string()),
3132 ),
3133 (
3134 "last_modified".to_string(),
3135 MetadataValue::String("2026-05-19".to_string()),
3136 ),
3137 (
3138 "protocol".to_string(),
3139 MetadataValue::String("https".to_string()),
3140 ),
3141 (
3142 "version".to_string(),
3143 MetadataValue::String("0.1.0".to_string()),
3144 ),
3145 (
3146 "deprecation_status".to_string(),
3147 MetadataValue::String("none".to_string()),
3148 ),
3149 ]);
3150
3151 let env = build_entity_envelope(
3152 &entity,
3153 0,
3154 None,
3155 None,
3156 None,
3157 OriginClass::FirstParty,
3158 &[],
3159 None,
3160 );
3161
3162 assert!(
3165 env.get("level").is_none(),
3166 "level must not be hoisted top-level"
3167 );
3168 assert!(
3169 env.get("stability").is_none(),
3170 "stability must not be hoisted"
3171 );
3172 assert!(
3173 env.get("created_date").is_none(),
3174 "created_date must not be hoisted"
3175 );
3176 assert!(
3177 env.get("last_modified").is_none(),
3178 "last_modified must not be hoisted"
3179 );
3180 assert_eq!(env["type"], "contract");
3182
3183 let metadata = env["metadata"].as_object().expect("metadata map");
3185 assert_eq!(metadata["level"], "M0");
3186 assert_eq!(metadata["stability"], "stable");
3187 assert_eq!(metadata["created_date"], "2026-01-01");
3188 assert_eq!(metadata["last_modified"], "2026-05-19");
3189 assert_eq!(metadata["protocol"], "https");
3190 assert_eq!(metadata["version"], "0.1.0");
3191 assert_eq!(metadata["deprecation_status"], "none");
3192
3193 for k in metadata.keys() {
3196 assert!(
3197 !k.starts_with('_'),
3198 "metadata map must not carry underscore-prefixed key `{k}`"
3199 );
3200 assert!(
3201 !["mem", "id", "type"].contains(&k.as_str()),
3202 "metadata map must not carry identity key `{k}` (it lives top-level)"
3203 );
3204 }
3205 }
3206
3207 #[test]
3211 fn build_entity_envelope_stub_carries_empty_metadata_map() {
3212 let mut entity = test_entity();
3213 entity.stub = true;
3214 entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
3215 entity.metadata = IndexMap::new();
3216 let env = build_entity_envelope(
3217 &entity,
3218 0,
3219 None,
3220 None,
3221 None,
3222 OriginClass::FirstParty,
3223 &[],
3224 None,
3225 );
3226 let metadata = env["metadata"]
3227 .as_object()
3228 .expect("metadata key present even on stubs");
3229 assert!(metadata.is_empty(), "stub metadata map must be empty");
3230 }
3231
3232 #[test]
3239 fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
3240 use crate::entity::MetadataValue;
3241 let mut entity = test_entity();
3242 entity.metadata = IndexMap::from([
3243 (
3244 "sections".to_string(),
3245 MetadataValue::String("user-supplied-shadow".to_string()),
3246 ),
3247 (
3248 "relationships".to_string(),
3249 MetadataValue::String("also-shadowed".to_string()),
3250 ),
3251 ]);
3252 let env = build_entity_envelope(
3253 &entity,
3254 0,
3255 None,
3256 None,
3257 None,
3258 OriginClass::FirstParty,
3259 &[],
3260 None,
3261 );
3262 assert!(
3264 env["sections"].is_object(),
3265 "top-level sections stays a map"
3266 );
3267 assert!(
3268 env["relationships"].is_array(),
3269 "top-level relationships stays an array"
3270 );
3271 let metadata = env["metadata"].as_object().expect("metadata map");
3273 assert_eq!(metadata["sections"], "user-supplied-shadow");
3274 assert_eq!(metadata["relationships"], "also-shadowed");
3275 }
3276
3277 #[test]
3281 fn build_entity_envelope_unfiltered_body_token_field_name() {
3282 let entity = test_entity();
3283 let env_filtered = build_entity_envelope(
3285 &entity,
3286 10,
3287 Some(42),
3288 None,
3289 None,
3290 OriginClass::FirstParty,
3291 &[],
3292 None,
3293 );
3294 assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
3295 assert!(
3296 env_filtered.get("_tokens_full").is_none(),
3297 "_tokens_full must not survive — rename is one-way"
3298 );
3299 let env_unfiltered = build_entity_envelope(
3301 &entity,
3302 10,
3303 None,
3304 None,
3305 None,
3306 OriginClass::FirstParty,
3307 &[],
3308 None,
3309 );
3310 assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
3311 assert!(env_unfiltered.get("_tokens_full").is_none());
3312 }
3313
3314 fn software_schema() -> Arc<Schema> {
3322 memstead_schema::builtins::load_builtin_schemas()
3323 .expect("builtins load")
3324 .into_iter()
3325 .find(|s| s.manifest.name == "software")
3326 .expect("software schema is a builtin")
3327 }
3328
3329 #[test]
3330 fn schema_verbosity_wire_round_trips() {
3331 assert_eq!(
3332 SchemaVerbosity::from_wire("full"),
3333 Some(SchemaVerbosity::Full)
3334 );
3335 assert_eq!(
3336 SchemaVerbosity::from_wire("lite"),
3337 Some(SchemaVerbosity::Lite)
3338 );
3339 assert_eq!(SchemaVerbosity::from_wire("brief"), None);
3340 assert_eq!(SchemaVerbosity::from_wire(""), None);
3341 assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
3342 assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
3343 assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
3344 }
3345
3346 #[test]
3352 fn exemplar_serves_at_full_and_lite_stays_byte_unchanged() {
3353 let manifest = r#"name: servefix
3354version: 1.0.0
3355description: serving fixture
3356when_to_use: tests
3357types:
3358 - sample
3359relationships:
3360 mode: strict
3361 definitions:
3362 - name: PART_OF
3363 description: hier
3364 default_weight: 3.0
3365 - name: _default
3366 description: fallback
3367 default_weight: 1.0
3368community:
3369 resolution: 1.0
3370 seed: 42
3371"#;
3372 let base_type = r#"name: sample
3373description: t
3374when_to_use: tests
3375sections:
3376 - key: body
3377 heading: Body
3378 required: true
3379 search_weight: 10.0
3380 catch_all: true
3381 write_rules: []
3382metadata_fields:
3383 - key: status
3384 description: state
3385 field_type: string
3386 enum_values: [draft, final]
3387 optional: true
3388title_weight: 100.0
3389text_fields:
3390 - body
3391hierarchy_relationship: PART_OF
3392no_self_loop_relationships: []
3393updatable_fields:
3394 - title
3395 - body
3396health_required_fields:
3397 - body
3398staleness_threshold_days: 90
3399write_rules: []
3400"#;
3401 let with_exemplar = format!(
3402 "{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"
3403 );
3404
3405 let plain = Arc::new(
3406 memstead_schema::loader::load_schema_from_memory(
3407 manifest,
3408 &[("sample".to_string(), base_type.to_string())],
3409 )
3410 .expect("fixture loads"),
3411 );
3412 let exemplary = Arc::new(
3413 memstead_schema::loader::load_schema_from_memory(
3414 manifest,
3415 &[("sample".to_string(), with_exemplar)],
3416 )
3417 .expect("fixture loads"),
3418 );
3419
3420 let full = build_schema_payload(
3422 &exemplary,
3423 vec![],
3424 SchemaVerbosity::Full,
3425 OriginClass::FirstParty,
3426 );
3427 let ex = &full["types"][0]["exemplar"];
3428 assert_eq!(ex["title"], "A Conforming Sample", "{full}");
3429 assert_eq!(ex["metadata"]["status"], "draft");
3430 assert_eq!(ex["sections"]["body"], "One canonical body paragraph.");
3431 assert_eq!(ex["relations"][0]["to"], "parent-placeholder");
3432 assert_eq!(ex["relations"][0]["type"], "PART_OF");
3433
3434 let full_plain = build_schema_payload(
3436 &plain,
3437 vec![],
3438 SchemaVerbosity::Full,
3439 OriginClass::FirstParty,
3440 );
3441 assert!(full_plain["types"][0].get("exemplar").is_none());
3442
3443 let lite_with = build_schema_payload(
3446 &exemplary,
3447 vec![],
3448 SchemaVerbosity::Lite,
3449 OriginClass::FirstParty,
3450 );
3451 let lite_without = build_schema_payload(
3452 &plain,
3453 vec![],
3454 SchemaVerbosity::Lite,
3455 OriginClass::FirstParty,
3456 );
3457 assert_eq!(
3458 serde_json::to_string(&lite_with).unwrap(),
3459 serde_json::to_string(&lite_without).unwrap(),
3460 "lite must not change when an exemplar exists"
3461 );
3462 assert!(
3463 !serde_json::to_string(&lite_with)
3464 .unwrap()
3465 .contains("exemplar"),
3466 "lite must not mention exemplars at all"
3467 );
3468 }
3469
3470 #[test]
3474 fn first_party_origin_is_labelled_and_keeps_prose() {
3475 let schema = software_schema();
3476 let full = build_schema_payload(
3477 &schema,
3478 vec!["v".into()],
3479 SchemaVerbosity::Full,
3480 OriginClass::FirstParty,
3481 );
3482 assert_eq!(full["origin"], "first-party");
3483 assert!(full["description"].is_string());
3485 let t = &full["types"].as_array().unwrap()[0];
3486 assert!(t.get("system_context").is_some());
3487 assert!(t.get("writing_guidance").is_some());
3488
3489 let lite = build_schema_payload(
3491 &schema,
3492 vec!["v".into()],
3493 SchemaVerbosity::Lite,
3494 OriginClass::FirstParty,
3495 );
3496 assert_eq!(lite["origin"], "first-party");
3497 }
3498
3499 #[test]
3504 fn constraints_and_severity_render_at_both_verbosities() {
3505 let manifest = r#"name: constrained
3506version: 1.0.0
3507description: constraint render fixture
3508when_to_use: render tests
3509types:
3510 - sample
3511relationships:
3512 mode: strict
3513 definitions:
3514 - name: PART_OF
3515 description: hier
3516 default_weight: 3.0
3517 - name: _default
3518 description: fallback
3519 default_weight: 1.0
3520community:
3521 resolution: 1.0
3522 seed: 42
3523"#;
3524 let type_yaml = r#"name: sample
3525description: t
3526when_to_use: tests
3527sections:
3528 - key: body
3529 heading: Body
3530 required: true
3531 search_weight: 10.0
3532 catch_all: true
3533 write_rules: []
3534metadata_fields:
3535 - key: status
3536 description: state
3537 field_type: string
3538 enum_values: [open, checked]
3539 optional: true
3540 - key: checked_by
3541 description: who
3542 field_type: string
3543 optional: true
3544title_weight: 100.0
3545text_fields:
3546 - body
3547hierarchy_relationship: PART_OF
3548no_self_loop_relationships: []
3549updatable_fields:
3550 - title
3551 - body
3552health_required_fields:
3553 - body
3554staleness_threshold_days: 90
3555required_outgoing:
3556 - relationships: [PART_OF]
3557 cardinality: at_least_one
3558 severity: block
3559constraints:
3560 - kind: requires_when
3561 field: checked_by
3562 when_field: status
3563 when_value: checked
3564 - kind: unique
3565 fields: [status, checked_by]
3566 - kind: enum_from_neighbour
3567 field: status
3568 rel_type: PART_OF
3569 section: body
3570 - kind: status_propagation
3571 field: status
3572 value: checked
3573 rel_type: PART_OF
3574 direction: incoming
3575write_rules: []
3576"#;
3577 let schema = Arc::new(
3578 memstead_schema::loader::load_schema_from_memory(
3579 manifest,
3580 &[("sample".to_string(), type_yaml.to_string())],
3581 )
3582 .expect("fixture loads"),
3583 );
3584
3585 let expected_constraints = serde_json::json!([
3590 {
3591 "kind": "requires_when",
3592 "field": "checked_by",
3593 "when_field": "status",
3594 "when_value": "checked",
3595 "severity": "warn",
3596 },
3597 {
3598 "kind": "unique",
3599 "fields": ["status", "checked_by"],
3600 "severity": "block",
3601 },
3602 {
3603 "kind": "enum_from_neighbour",
3604 "field": "status",
3605 "rel_type": "PART_OF",
3606 "section": "body",
3607 "severity": "warn",
3608 },
3609 {
3610 "kind": "status_propagation",
3611 "field": "status",
3612 "value": "checked",
3613 "rel_type": "PART_OF",
3614 "direction": "incoming",
3615 "severity": "warn",
3616 },
3617 ]);
3618
3619 let full = build_schema_payload(
3620 &schema,
3621 vec![],
3622 SchemaVerbosity::Full,
3623 OriginClass::FirstParty,
3624 );
3625 let t = &full["types"].as_array().unwrap()[0];
3626 assert_eq!(t["constraints"], expected_constraints);
3627 assert_eq!(t["required_outgoing"][0]["severity"], "block");
3628
3629 let lite = build_schema_payload(
3630 &schema,
3631 vec![],
3632 SchemaVerbosity::Lite,
3633 OriginClass::FirstParty,
3634 );
3635 let ts = &lite["types_summary"].as_array().unwrap()[0];
3636 assert_eq!(ts["constraints"], expected_constraints);
3637 assert_eq!(ts["required_outgoing"][0]["severity"], "block");
3638
3639 let fmt_manifest = r#"name: formatted
3642version: 1.0.0
3643description: format render fixture
3644when_to_use: render tests
3645types:
3646 - plan
3647relationships:
3648 mode: strict
3649 definitions:
3650 - name: PART_OF
3651 description: hier
3652 default_weight: 1.0
3653 - name: _default
3654 description: fallback
3655 default_weight: 1.0
3656community:
3657 resolution: 1.0
3658 seed: 42
3659"#;
3660 let fmt_type = r#"name: plan
3661description: t
3662when_to_use: tests
3663sections:
3664 - key: body
3665 heading: Body
3666 required: true
3667 search_weight: 10.0
3668 catch_all: true
3669 write_rules: []
3670 - key: meilensteine
3671 heading: Meilensteine
3672 required: false
3673 search_weight: 5.0
3674 catch_all: false
3675 write_rules: []
3676 content: "(heading(3) list(bullet))+"
3677 item_pattern: '\*\*(?<name>[^*]+)\*\*'
3678 example: |
3679 ### Phase 1
3680 - **Kickoff**
3681 format_severity: warn
3682 - key: tabelle
3683 heading: Tabelle
3684 required: false
3685 search_weight: 5.0
3686 catch_all: false
3687 write_rules: []
3688 content: "table"
3689 table:
3690 columns: [Name, Datum]
3691 column_patterns:
3692 Datum: '\d{4}-\d{2}-\d{2}'
3693 - key: belege
3694 heading: Belege
3695 required: false
3696 search_weight: 5.0
3697 catch_all: false
3698 write_rules: []
3699 content: "paragraph+"
3700 item_pattern: '(?<quelle>\S[^|]*?) \| (?<aussage>.+)'
3701metadata_fields: []
3702title_weight: 100.0
3703text_fields:
3704 - body
3705hierarchy_relationship: PART_OF
3706no_self_loop_relationships: []
3707updatable_fields:
3708 - title
3709 - body
3710health_required_fields:
3711 - body
3712staleness_threshold_days: 90
3713write_rules: []
3714"#;
3715 let fmt_schema = Arc::new(
3716 memstead_schema::loader::load_schema_from_memory(
3717 fmt_manifest,
3718 &[("plan".to_string(), fmt_type.to_string())],
3719 )
3720 .expect("format fixture loads"),
3721 );
3722 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
3723 let payload =
3724 build_schema_payload(&fmt_schema, vec![], verbosity, OriginClass::FirstParty);
3725 let sections_key = match verbosity {
3726 SchemaVerbosity::Full => &payload["types"][0]["sections"],
3727 SchemaVerbosity::Lite => &payload["types_summary"][0]["sections"],
3728 };
3729 let secs = sections_key.as_array().unwrap();
3730 let meilensteine = secs
3731 .iter()
3732 .find(|s| s["key"] == "meilensteine")
3733 .expect("declared section present");
3734 assert_eq!(
3735 meilensteine["content"], "(heading(3) list(bullet))+",
3736 "{verbosity:?} carries content"
3737 );
3738 assert!(
3739 meilensteine["item_pattern"]
3740 .as_str()
3741 .unwrap()
3742 .contains("name")
3743 );
3744 assert!(
3745 meilensteine["example"]
3746 .as_str()
3747 .unwrap()
3748 .contains("Kickoff")
3749 );
3750 assert_eq!(meilensteine["format_severity"], "warn");
3751 let tabelle = secs.iter().find(|s| s["key"] == "tabelle").unwrap();
3752 assert_eq!(tabelle["format_severity"], "block", "default renders");
3753 assert_eq!(tabelle["table"]["columns"][0], "Name");
3754 assert!(
3755 tabelle["table"]["column_patterns"]["Datum"]
3756 .as_str()
3757 .is_some()
3758 );
3759 let belege = secs.iter().find(|s| s["key"] == "belege").unwrap();
3760 assert_eq!(belege["content"], "paragraph+");
3761 assert!(belege["item_pattern"].as_str().unwrap().contains("quelle"));
3762 let body = secs.iter().find(|s| s["key"] == "body").unwrap();
3763 assert!(
3764 body.get("content").is_none() && body.get("format_severity").is_none(),
3765 "undeclared section keeps its pre-plan shape"
3766 );
3767 }
3768
3769 let plain_full = build_schema_payload(
3772 &software_schema(),
3773 vec![],
3774 SchemaVerbosity::Full,
3775 OriginClass::FirstParty,
3776 );
3777 let pt = &plain_full["types"].as_array().unwrap()[0];
3778 assert_eq!(pt["constraints"], serde_json::json!([]));
3779 let plain_lite = build_schema_payload(
3780 &software_schema(),
3781 vec![],
3782 SchemaVerbosity::Lite,
3783 OriginClass::FirstParty,
3784 );
3785 let pts = &plain_lite["types_summary"].as_array().unwrap()[0];
3786 assert_eq!(pts["constraints"], serde_json::json!([]));
3787 }
3788
3789 #[test]
3799 fn third_party_origin_forces_structural_only_even_under_full() {
3800 let schema = software_schema();
3801 let full_requested = build_schema_payload(
3802 &schema,
3803 vec!["v".into()],
3804 SchemaVerbosity::Full,
3805 OriginClass::ThirdParty,
3806 );
3807
3808 assert_eq!(full_requested["origin"], "third-party");
3810
3811 assert!(
3814 full_requested.get("types").is_none(),
3815 "third-party omits the rich `types` array even under full"
3816 );
3817 assert!(
3818 full_requested.get("relationships").is_none(),
3819 "third-party omits the rich `relationships` array even under full"
3820 );
3821 assert!(
3822 full_requested["types_summary"].is_array(),
3823 "third-party serves the structural `types_summary` skeleton"
3824 );
3825 assert!(
3826 full_requested["relationships_summary"].is_array(),
3827 "third-party serves the structural `relationships_summary` skeleton"
3828 );
3829
3830 assert!(
3832 full_requested.get("description").is_none(),
3833 "third-party drops schema description prose"
3834 );
3835 assert!(
3836 full_requested.get("when_to_use").is_none(),
3837 "third-party drops schema when_to_use prose"
3838 );
3839 assert!(
3840 full_requested.get("default_writing_guidance").is_none(),
3841 "third-party drops default_writing_guidance prose"
3842 );
3843
3844 for t in full_requested["types_summary"].as_array().unwrap() {
3846 assert!(
3847 t.get("system_context").is_none(),
3848 "third-party drops system_context"
3849 );
3850 assert!(
3851 t.get("writing_guidance").is_none(),
3852 "third-party drops writing_guidance"
3853 );
3854 assert!(
3855 t.get("description").is_none(),
3856 "third-party drops type description"
3857 );
3858 for s in t["sections"].as_array().unwrap() {
3859 assert!(
3860 s.get("write_rules").is_none(),
3861 "third-party drops section write_rules"
3862 );
3863 }
3864 }
3865 for r in full_requested["relationships_summary"].as_array().unwrap() {
3867 assert!(
3868 r.get("description").is_none(),
3869 "third-party drops rel description"
3870 );
3871 assert!(
3872 r.get("when_to_use").is_none(),
3873 "third-party drops rel when_to_use"
3874 );
3875 }
3876
3877 let lite_requested = build_schema_payload(
3881 &schema,
3882 vec!["v".into()],
3883 SchemaVerbosity::Lite,
3884 OriginClass::ThirdParty,
3885 );
3886 assert_eq!(
3887 full_requested, lite_requested,
3888 "third-party full must collapse to the lite skeleton"
3889 );
3890 }
3891
3892 #[test]
3893 fn full_payload_carries_the_rich_arrays_and_prose() {
3894 let schema = software_schema();
3895 let full = build_schema_payload(
3896 &schema,
3897 vec!["v".into()],
3898 SchemaVerbosity::Full,
3899 OriginClass::FirstParty,
3900 );
3901
3902 assert!(full["types"].is_array(), "full has `types`");
3904 assert!(full["relationships"].is_array(), "full has `relationships`");
3905 assert!(
3906 full.get("types_summary").is_none(),
3907 "full omits `types_summary`"
3908 );
3909 assert!(
3910 full.get("relationships_summary").is_none(),
3911 "full omits `relationships_summary`"
3912 );
3913 assert!(
3914 full["description"].is_string(),
3915 "full keeps schema description"
3916 );
3917 assert!(
3918 full["when_to_use"].is_string(),
3919 "full keeps schema when_to_use"
3920 );
3921 assert_eq!(full["alias_target_rel_type"], "REFERENCES");
3922
3923 let t = &full["types"].as_array().unwrap()[0];
3925 assert!(t["description"].is_string());
3926 assert!(t.get("writing_guidance").is_some());
3927 assert!(t.get("system_context").is_some());
3928 let r = &full["relationships"].as_array().unwrap()[0];
3930 assert!(r["description"].is_string());
3931 assert!(r.get("when_to_use").is_some());
3932 assert!(r.get("default_weight").is_some());
3933 }
3934
3935 #[test]
3944 fn required_outgoing_reported_with_cardinality_at_both_levels() {
3945 let reg = memstead_schema::SchemaRegistry::builtin();
3946 let project = reg
3947 .get("project", &semver::Version::new(0, 2, 0))
3948 .expect("project is a built-in");
3949
3950 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
3951 let payload =
3952 build_schema_payload(&project, vec![], verbosity, OriginClass::FirstParty);
3953 let types_key = if verbosity == SchemaVerbosity::Full {
3954 "types"
3955 } else {
3956 "types_summary"
3957 };
3958 let types = payload[types_key].as_array().expect("types array");
3959
3960 let mut saw_evidence = false;
3961 let mut saw_memo = false;
3962 for t in types {
3963 let ro = t
3964 .get("required_outgoing")
3965 .unwrap_or_else(|| panic!("type {} omits required_outgoing", t["name"]))
3966 .as_array()
3967 .expect("required_outgoing is an array for every type");
3968 if t["name"] == "evidence" {
3969 saw_evidence = true;
3970 assert_eq!(ro.len(), 1, "evidence declares one block");
3971 assert_eq!(
3972 ro[0]["relationships"],
3973 serde_json::json!(["STRENGTHENS", "WEAKENS", "VALIDATES", "CONTRADICTS"]),
3974 "relationship alternatives in declaration order"
3975 );
3976 assert_eq!(
3977 ro[0]["cardinality"], "at_least_one",
3978 "cardinality rendered as declared — the open upper bound \
3979 stays open, never a finite number"
3980 );
3981 } else if t["name"] == "memo" {
3982 saw_memo = true;
3985 assert!(ro.is_empty(), "memo declares no blocks → empty list");
3986 }
3987 }
3988 assert!(saw_evidence, "project schema carries the evidence type");
3989 assert!(saw_memo, "project schema carries the memo type");
3990
3991 let note = payload["no_self_loop_relationships_effect"]
3994 .as_str()
3995 .expect("effect note present at both verbosity levels");
3996 assert!(note.contains("self-loop"), "names the actual effect");
3997 assert!(
3998 !note.contains("propagates impact") || note.contains("does not propagate"),
3999 "claims no propagation behaviour beyond the self-loop refusal"
4000 );
4001 assert!(
4002 note.contains("status_propagation"),
4003 "deprecation pointer names the real propagation declaration"
4004 );
4005 }
4006 }
4007
4008 #[test]
4009 fn lite_payload_is_the_structural_skeleton_without_prose() {
4010 let schema = software_schema();
4011 let lite = build_schema_payload(
4012 &schema,
4013 vec!["v".into()],
4014 SchemaVerbosity::Lite,
4015 OriginClass::FirstParty,
4016 );
4017
4018 let types = lite["types_summary"]
4020 .as_array()
4021 .expect("lite has `types_summary`");
4022 let rels = lite["relationships_summary"]
4023 .as_array()
4024 .expect("lite has `relationships_summary`");
4025 assert!(lite.get("types").is_none(), "lite omits rich `types`");
4026 assert!(
4027 lite.get("relationships").is_none(),
4028 "lite omits rich `relationships`"
4029 );
4030
4031 assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
4034
4035 assert!(
4037 lite.get("description").is_none(),
4038 "lite drops schema description"
4039 );
4040 assert!(
4041 lite.get("when_to_use").is_none(),
4042 "lite drops schema when_to_use"
4043 );
4044 assert!(
4045 lite.get("default_writing_guidance").is_none(),
4046 "lite drops default_writing_guidance"
4047 );
4048
4049 for t in types {
4052 assert!(t["name"].is_string());
4053 let sections = t["sections"].as_array().expect("lite type has sections");
4054 for s in sections {
4055 assert!(s["key"].is_string(), "section carries its key");
4056 assert!(s["required"].is_boolean(), "section carries required flag");
4057 assert!(
4058 s.get("write_rules").is_none(),
4059 "lite section drops write_rules prose"
4060 );
4061 assert!(s.get("heading").is_none(), "lite section drops heading");
4062 }
4063 assert!(
4064 t.get("description").is_none(),
4065 "lite type drops description"
4066 );
4067 assert!(
4068 t.get("writing_guidance").is_none(),
4069 "lite type drops writing_guidance"
4070 );
4071 assert!(
4072 t.get("system_context").is_none(),
4073 "lite type drops system_context"
4074 );
4075 assert!(
4079 t.get("no_self_loop_relationships").is_some(),
4080 "lite type keeps no_self_loop_relationships"
4081 );
4082 assert!(
4086 t.get("required_outgoing").is_some_and(|v| v.is_array()),
4087 "lite type keeps required_outgoing as an array"
4088 );
4089 if let Some(fields) = t["fields"].as_array() {
4091 for f in fields {
4092 assert!(f["name"].is_string());
4093 assert!(f["required"].is_boolean());
4094 assert!(
4095 f.get("description").is_none(),
4096 "lite field drops description"
4097 );
4098 }
4099 }
4100 }
4101
4102 for r in rels {
4105 assert!(r["name"].is_string());
4106 assert!(
4107 r.get("allowed_sources").is_some(),
4108 "lite rel has allowed_sources"
4109 );
4110 assert!(
4111 r.get("allowed_targets").is_some(),
4112 "lite rel has allowed_targets"
4113 );
4114 assert!(
4115 r.get("manual_authoring").is_some(),
4116 "lite rel keeps manual_authoring"
4117 );
4118 assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
4119 assert!(
4120 r.get("per_edge_description").is_some(),
4121 "lite rel keeps per_edge_description"
4122 );
4123 assert!(r.get("description").is_none(), "lite rel drops description");
4124 assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
4125 assert!(
4126 r.get("default_weight").is_none(),
4127 "lite rel drops default_weight"
4128 );
4129 }
4130 }
4131
4132 #[test]
4133 fn lite_is_measurably_smaller_than_full() {
4134 let schema = software_schema();
4135 let full = build_schema_payload(
4136 &schema,
4137 vec!["v".into()],
4138 SchemaVerbosity::Full,
4139 OriginClass::FirstParty,
4140 );
4141 let lite = build_schema_payload(
4142 &schema,
4143 vec!["v".into()],
4144 SchemaVerbosity::Lite,
4145 OriginClass::FirstParty,
4146 );
4147 let full_len = serde_json::to_string(&full).unwrap().len();
4148 let lite_len = serde_json::to_string(&lite).unwrap().len();
4149 assert!(
4150 lite_len * 2 < full_len,
4151 "lite ({lite_len} B) must be well under half of full ({full_len} B)"
4152 );
4153 }
4154
4155 #[test]
4156 fn lite_full_carry_the_same_type_and_rel_names() {
4157 let schema = software_schema();
4160 let full = build_schema_payload(
4161 &schema,
4162 vec!["v".into()],
4163 SchemaVerbosity::Full,
4164 OriginClass::FirstParty,
4165 );
4166 let lite = build_schema_payload(
4167 &schema,
4168 vec!["v".into()],
4169 SchemaVerbosity::Lite,
4170 OriginClass::FirstParty,
4171 );
4172
4173 let names = |arr: &serde_json::Value| -> Vec<String> {
4174 arr.as_array()
4175 .unwrap()
4176 .iter()
4177 .map(|v| v["name"].as_str().unwrap().to_string())
4178 .collect()
4179 };
4180 assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
4181 assert_eq!(
4182 names(&full["relationships"]),
4183 names(&lite["relationships_summary"])
4184 );
4185 }
4186}