1use std::collections::{BTreeMap, BTreeSet};
4use std::fmt::Write as _;
5
6use code_system_graph_model::{
7 Edge, EdgeId, EpistemicStatus, Evidence, EvidenceId, Node, NodeId, NodeKind, Provenance, RepoFreshnessState, RepoId
8};
9use schemars::{JsonSchema, schema_for};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use crate::{
14 ChangeImpactReport, ChangeRequest, CompatibilityReport, CompatibilityStatus, ImpactReport, ImpactRequest, PullRequestInspection, SearchReport, SearchRequest, TraversalReport, TraversalRequest
15};
16
17pub const INTERFACE_SCHEMA_VERSION: u32 = 1;
19pub const INTERFACE_RESULT_VERSION: u32 = 1;
21pub const DELIVERY_METADATA_VERSION: u32 = 1;
23pub const MAX_EXPORT_NODES: usize = 100_000;
25pub const MAX_EXPORT_EDGES: usize = 1_000_000;
27const DEFAULT_EXPORT_NODES: usize = 10_000;
28const DEFAULT_EXPORT_EDGES: usize = 50_000;
29
30#[derive(
32 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
33)]
34#[serde(rename_all = "kebab-case")]
35pub enum ContractAction {
36 List,
38 Show,
40 Validate,
42 Diff,
44 ExplainLink,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50pub struct ContractRequest {
51 pub action: ContractAction,
53 pub contract: Option<NodeId>,
55 pub related_contract: Option<NodeId>,
57 pub limit: usize,
59}
60
61impl Default for ContractRequest {
62 fn default() -> Self {
63 Self {
64 action: ContractAction::List,
65 contract: None,
66 related_contract: None,
67 limit: 100,
68 }
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74pub struct ContractCompatibility {
75 pub contract_id: NodeId,
77 pub report: CompatibilityReport,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
85pub struct EvidenceMetadata {
86 pub id: EvidenceId,
88 pub repo_id: Option<RepoId>,
90 pub file_path: Option<String>,
92 pub start_line: Option<u32>,
94 pub end_line: Option<u32>,
96 pub extractor: String,
98 pub extractor_version: String,
100 pub provenance: Provenance,
102 pub confidence: f32,
104 pub observed_at_commit: Option<String>,
106 pub content_hash: Option<String>,
108}
109
110impl From<&Evidence> for EvidenceMetadata {
111 fn from(value: &Evidence) -> Self {
112 Self {
113 id: value.id.clone(),
114 repo_id: value.repo_id.clone(),
115 file_path: value.file_path.clone(),
116 start_line: value.start_line,
117 end_line: value.end_line,
118 extractor: value.extractor.clone(),
119 extractor_version: value.extractor_version.clone(),
120 provenance: value.provenance,
121 confidence: value.confidence,
122 observed_at_commit: value.observed_at_commit.clone(),
123 content_hash: value.content_hash.clone(),
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
130pub struct ContractLink {
131 pub edge: Edge,
133 pub evidence: Vec<EvidenceMetadata>,
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
139pub struct ContractView {
140 pub contract: Node,
142 pub links: Vec<ContractLink>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
148pub struct ContractFinding {
149 pub code: String,
151 pub path: String,
153 pub status: CompatibilityStatus,
155 pub factors: Vec<String>,
157 pub recommended_validations: Vec<String>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
163pub struct ContractCompatibilitySummary {
164 pub contract_id: NodeId,
166 pub status: CompatibilityStatus,
168 pub before_fingerprint: String,
170 pub after_fingerprint: String,
172 pub findings: Vec<ContractFinding>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
178pub struct ContractDifference {
179 pub field: String,
181 pub before: Option<String>,
183 pub after: Option<String>,
185}
186
187#[derive(
189 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
190)]
191#[serde(rename_all = "snake_case")]
192pub enum ContractIssueSeverity {
193 Unknown,
195 Warning,
197 Error,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
203pub struct ContractIssue {
204 pub code: String,
206 pub severity: ContractIssueSeverity,
208 pub entity_id: Option<String>,
210 pub message: String,
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
216pub struct ContractReport {
217 pub schema_version: u32,
219 pub result_version: u32,
221 pub action: ContractAction,
223 pub contracts: Vec<ContractView>,
225 pub links: Vec<ContractLink>,
227 pub compatibility: Vec<ContractCompatibilitySummary>,
229 pub differences: Vec<ContractDifference>,
231 pub issues: Vec<ContractIssue>,
233 pub valid: Option<bool>,
235 pub complete: bool,
237 pub truncated: bool,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
243#[serde(rename_all = "snake_case")]
244pub enum ExportFormat {
245 Json,
247 GraphMl,
249 Markdown,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
255pub struct ExportRequest {
256 pub format: ExportFormat,
258 pub max_nodes: usize,
260 pub max_edges: usize,
262}
263
264impl Default for ExportRequest {
265 fn default() -> Self {
266 Self {
267 format: ExportFormat::Json,
268 max_nodes: DEFAULT_EXPORT_NODES,
269 max_edges: DEFAULT_EXPORT_EDGES,
270 }
271 }
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
276pub struct ExportReport {
277 pub schema_version: u32,
279 pub result_version: u32,
281 pub format: ExportFormat,
283 pub content: String,
285 pub exported_nodes: usize,
287 pub exported_edges: usize,
289 pub omitted_nodes: usize,
291 pub omitted_edges: usize,
293 pub truncated: bool,
295 pub warnings: Vec<Warning>,
297}
298
299#[derive(
301 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
302)]
303#[serde(rename_all = "snake_case")]
304pub enum DoctorCategory {
305 Schema,
307 Integrity,
309 Freshness,
311 Provider,
313 Config,
315}
316
317#[derive(
319 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
320)]
321#[serde(rename_all = "snake_case")]
322pub enum DoctorStatus {
323 Healthy,
325 Degraded,
327 Failed,
329 Unknown,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
335pub struct SchemaDoctorInput {
336 pub name: String,
338 pub expected_version: u32,
340 pub actual_version: Option<u32>,
342 pub metadata_consistent: Option<bool>,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
348pub struct IntegrityDoctorInput {
349 pub name: String,
351 pub passed: Option<bool>,
353 pub detail: Option<String>,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
359pub struct FreshnessDoctorInput {
360 pub repo_id: RepoId,
362 pub state: RepoFreshnessState,
364 pub detail: Option<String>,
366}
367
368#[derive(
370 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
371)]
372#[serde(rename_all = "snake_case")]
373pub enum ProviderDoctorStatus {
374 Available,
376 IndexMissing,
378 Stale,
380 Unavailable,
382 Incompatible,
384 InvalidResponse,
386 TimedOut,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
392pub struct ProviderDoctorInput {
393 pub name: String,
395 pub status: ProviderDoctorStatus,
397 pub detail: Option<String>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
403pub struct ConfigDoctorInput {
404 pub name: String,
406 pub valid: Option<bool>,
408 pub detail: Option<String>,
410}
411
412#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
414pub struct DoctorRequest {
415 pub schema: Vec<SchemaDoctorInput>,
417 pub integrity: Vec<IntegrityDoctorInput>,
419 pub freshness: Vec<FreshnessDoctorInput>,
421 pub providers: Vec<ProviderDoctorInput>,
423 pub config: Vec<ConfigDoctorInput>,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
429pub struct DoctorCheck {
430 pub category: DoctorCategory,
432 pub name: String,
434 pub status: DoctorStatus,
436 pub summary: String,
438 pub remediation: Option<String>,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
444pub struct DoctorReport {
445 pub schema_version: u32,
447 pub result_version: u32,
449 pub status: DoctorStatus,
451 pub checks: Vec<DoctorCheck>,
453 pub complete: bool,
455 pub healthy_checks: usize,
457 pub degraded_checks: usize,
459 pub failed_checks: usize,
461 pub unknown_checks: usize,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
467pub struct Pagination {
468 pub version: u32,
470 pub offset: usize,
472 pub limit: usize,
474 pub total: usize,
476 pub returned: usize,
478 pub has_more: bool,
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
484pub struct Summary {
485 pub version: u32,
487 pub code: String,
489 pub title: String,
491 pub detail: String,
493 pub complete: bool,
495}
496
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
499pub struct Ambiguity {
500 pub version: u32,
502 pub code: String,
504 pub message: String,
506 pub candidates: Vec<String>,
508 pub remediation: String,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
514pub struct Warning {
515 pub version: u32,
517 pub code: String,
519 pub message: String,
521}
522
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
525pub struct NextAction {
526 pub version: u32,
528 pub code: String,
530 pub reason: String,
532 pub command: Option<String>,
534}
535
536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
538pub struct Page<T> {
539 pub schema_version: u32,
541 pub result_version: u32,
543 pub items: Vec<T>,
545 pub pagination: Pagination,
547 pub summary: Summary,
549 pub ambiguities: Vec<Ambiguity>,
551 pub warnings: Vec<Warning>,
553 pub next_actions: Vec<NextAction>,
555}
556
557#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
559pub struct PublicSchema {
560 pub name: String,
562 pub version: u32,
564 pub schema: serde_json::Value,
566}
567
568#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
570pub struct PublicSchemaCatalog {
571 pub schema_version: u32,
573 pub result_version: u32,
575 pub schemas: Vec<PublicSchema>,
577}
578
579#[derive(
581 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
582)]
583#[serde(rename_all = "snake_case")]
584pub enum DomainErrorKind {
585 InvalidInput,
587 NotFound,
589 Ambiguous,
591 Conflict,
593 Partial,
595 Unavailable,
597 Timeout,
599 Cancelled,
601 Internal,
603}
604
605#[derive(
607 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
608)]
609#[repr(u8)]
610#[serde(rename_all = "snake_case")]
611pub enum ExitCode {
612 Success = 0,
614 InvalidInput = 2,
616 NotFound = 3,
618 Ambiguous = 4,
620 Conflict = 5,
622 Partial = 6,
624 Unavailable = 7,
626 Timeout = 8,
628 Internal = 70,
630 Cancelled = 130,
632}
633
634impl ExitCode {
635 #[must_use]
637 pub const fn value(self) -> u8 {
638 self as u8
639 }
640}
641
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Error)]
644#[serde(tag = "kind", rename_all = "snake_case")]
645pub enum InterfaceError {
646 #[error("contract action `{action:?}` requires `{field}`")]
648 MissingContractField {
649 action: ContractAction,
651 field: String,
653 },
654 #[error("graph entity `{0}` was not found")]
656 NotFound(String),
657 #[error("duplicate {entity} identifier `{id}`")]
659 DuplicateIdentity {
660 entity: String,
662 id: String,
664 },
665 #[error("requested {name} bound {value} is outside 1..={maximum}")]
667 InvalidBound {
668 name: String,
670 value: usize,
672 maximum: usize,
674 },
675 #[error("failed to serialize `{0}`")]
677 Serialization(String),
678}
679
680pub fn inspect_contracts(
690 nodes: &[Node],
691 edges: &[Edge],
692 evidence: &[Evidence],
693 compatibility: &[ContractCompatibility],
694 request: &ContractRequest,
695) -> Result<ContractReport, InterfaceError> {
696 validate_bound("contract result", request.limit, MAX_EXPORT_NODES)?;
697 let node_index = index_nodes(nodes)?;
698 let evidence_index = index_evidence(evidence)?;
699 let edge_index = index_edges(edges)?;
700 let contract_ids = node_index
701 .values()
702 .filter(|node| is_contract_kind(node.kind))
703 .map(|node| node.id.clone())
704 .collect::<BTreeSet<_>>();
705 let mut issues = validate_contract_inputs(&node_index, &edge_index, &evidence_index);
706 let context = ContractContext {
707 nodes: &node_index,
708 contract_ids: &contract_ids,
709 edges: &edge_index,
710 evidence: &evidence_index,
711 compatibility,
712 };
713 let mut selected = select_contract_action(&context, request, &mut issues)?;
714 if issues
715 .iter()
716 .any(|issue| issue.severity != ContractIssueSeverity::Warning)
717 {
718 selected.complete = false;
719 }
720 issues.sort_by(issue_order);
721 issues.dedup();
722 Ok(ContractReport {
723 schema_version: INTERFACE_SCHEMA_VERSION,
724 result_version: INTERFACE_RESULT_VERSION,
725 action: request.action,
726 contracts: selected.contracts,
727 links: selected.links,
728 compatibility: selected.compatibility,
729 differences: selected.differences,
730 issues,
731 valid: selected.valid,
732 complete: selected.complete,
733 truncated: selected.truncated,
734 })
735}
736
737struct ContractContext<'a> {
738 nodes: &'a BTreeMap<NodeId, &'a Node>,
739 contract_ids: &'a BTreeSet<NodeId>,
740 edges: &'a BTreeMap<EdgeId, &'a Edge>,
741 evidence: &'a BTreeMap<EvidenceId, &'a Evidence>,
742 compatibility: &'a [ContractCompatibility],
743}
744
745struct ContractSelection {
746 contracts: Vec<ContractView>,
747 links: Vec<ContractLink>,
748 compatibility: Vec<ContractCompatibilitySummary>,
749 differences: Vec<ContractDifference>,
750 valid: Option<bool>,
751 complete: bool,
752 truncated: bool,
753}
754
755impl ContractSelection {
756 fn complete() -> Self {
757 Self {
758 contracts: Vec::new(),
759 links: Vec::new(),
760 compatibility: Vec::new(),
761 differences: Vec::new(),
762 valid: None,
763 complete: true,
764 truncated: false,
765 }
766 }
767}
768
769fn select_contract_action(
770 context: &ContractContext<'_>,
771 request: &ContractRequest,
772 issues: &mut Vec<ContractIssue>,
773) -> Result<ContractSelection, InterfaceError> {
774 match request.action {
775 ContractAction::List => Ok(select_contract_list(context, request.limit)),
776 ContractAction::Show => select_contract_show(context, request, issues),
777 ContractAction::Validate => select_contract_validation(context, request, issues),
778 ContractAction::Diff => select_contract_diff(context, request, issues),
779 ContractAction::ExplainLink => select_contract_link(context, request, issues),
780 }
781}
782
783fn select_contract_list(context: &ContractContext<'_>, limit: usize) -> ContractSelection {
784 let ordered = ordered_contracts(context.nodes);
785 let mut selected = ContractSelection::complete();
786 selected.truncated = ordered.len() > limit;
787 selected.contracts = ordered
788 .into_iter()
789 .take(limit)
790 .map(|node| ContractView {
791 contract: node.clone(),
792 links: Vec::new(),
793 })
794 .collect();
795 selected
796}
797
798fn select_contract_show(
799 context: &ContractContext<'_>,
800 request: &ContractRequest,
801 issues: &mut Vec<ContractIssue>,
802) -> Result<ContractSelection, InterfaceError> {
803 let id = required_contract(request, false)?;
804 let node = require_contract(context.nodes, context.contract_ids, id)?;
805 let (links, truncated) = links_for_node(id, context.edges, context.evidence, request.limit);
806 let mut selected = ContractSelection::complete();
807 selected.contracts.push(ContractView {
808 contract: node.clone(),
809 links,
810 });
811 selected.truncated = truncated;
812 selected.compatibility =
813 compatibility_for(&[id], context.compatibility, issues, &mut selected.complete);
814 Ok(selected)
815}
816
817fn select_contract_validation(
818 context: &ContractContext<'_>,
819 request: &ContractRequest,
820 issues: &mut Vec<ContractIssue>,
821) -> Result<ContractSelection, InterfaceError> {
822 let mut selected = ContractSelection::complete();
823 if let Some(id) = request.contract.as_ref() {
824 let node = require_contract(context.nodes, context.contract_ids, id)?;
825 selected.contracts.push(ContractView {
826 contract: node.clone(),
827 links: Vec::new(),
828 });
829 issues.retain(|issue| issue_applies_to(issue, id, context.edges));
830 }
831 let conclusive = !issues
832 .iter()
833 .any(|issue| issue.severity == ContractIssueSeverity::Unknown);
834 selected.complete = conclusive;
835 selected.valid = Some(
836 conclusive
837 && !issues
838 .iter()
839 .any(|issue| issue.severity == ContractIssueSeverity::Error),
840 );
841 Ok(selected)
842}
843
844fn select_contract_diff(
845 context: &ContractContext<'_>,
846 request: &ContractRequest,
847 issues: &mut Vec<ContractIssue>,
848) -> Result<ContractSelection, InterfaceError> {
849 let before_id = required_contract(request, false)?;
850 let after_id = required_contract(request, true)?;
851 let before = require_contract(context.nodes, context.contract_ids, before_id)?;
852 let after = require_contract(context.nodes, context.contract_ids, after_id)?;
853 let mut selected = ContractSelection::complete();
854 selected.contracts = vec![
855 ContractView {
856 contract: before.clone(),
857 links: Vec::new(),
858 },
859 ContractView {
860 contract: after.clone(),
861 links: Vec::new(),
862 },
863 ];
864 selected.differences = contract_differences(before, after);
865 selected.compatibility = compatibility_for(
866 &[before_id, after_id],
867 context.compatibility,
868 issues,
869 &mut selected.complete,
870 );
871 Ok(selected)
872}
873
874fn select_contract_link(
875 context: &ContractContext<'_>,
876 request: &ContractRequest,
877 issues: &mut Vec<ContractIssue>,
878) -> Result<ContractSelection, InterfaceError> {
879 let source_id = required_contract(request, false)?;
880 let target_id = required_contract(request, true)?;
881 let source = require_contract(context.nodes, context.contract_ids, source_id)?;
882 let target = require_contract(context.nodes, context.contract_ids, target_id)?;
883 let mut selected = ContractSelection::complete();
884 selected.contracts = vec![
885 ContractView {
886 contract: source.clone(),
887 links: Vec::new(),
888 },
889 ContractView {
890 contract: target.clone(),
891 links: Vec::new(),
892 },
893 ];
894 let mut matching = context
895 .edges
896 .values()
897 .filter(|edge| {
898 (&edge.source == source_id && &edge.target == target_id)
899 || (&edge.source == target_id && &edge.target == source_id)
900 })
901 .map(|edge| link_from_edge(edge, context.evidence))
902 .collect::<Vec<_>>();
903 matching.sort_by(|left, right| left.edge.id.cmp(&right.edge.id));
904 selected.truncated = matching.len() > request.limit;
905 selected.links = matching.into_iter().take(request.limit).collect();
906 if selected.links.is_empty() {
907 selected.complete = false;
908 issues.push(contract_issue(
909 "contract.link_not_observed",
910 ContractIssueSeverity::Unknown,
911 None,
912 "No direct link was observed; absence is not proof of independence.",
913 ));
914 }
915 Ok(selected)
916}
917
918pub fn export_graph(
928 nodes: &[Node],
929 edges: &[Edge],
930 evidence: &[Evidence],
931 request: &ExportRequest,
932) -> Result<ExportReport, InterfaceError> {
933 validate_bound("node export", request.max_nodes, MAX_EXPORT_NODES)?;
934 validate_bound("edge export", request.max_edges, MAX_EXPORT_EDGES)?;
935 let node_index = index_nodes(nodes)?;
936 let _ = index_edges(edges)?;
937 let evidence_index = index_evidence(evidence)?;
938 let mut ordered_nodes = nodes.iter().collect::<Vec<_>>();
939 ordered_nodes.sort_by(|left, right| {
940 left.stable_key
941 .cmp(&right.stable_key)
942 .then_with(|| left.id.cmp(&right.id))
943 });
944 let selected_nodes = ordered_nodes
945 .into_iter()
946 .take(request.max_nodes)
947 .cloned()
948 .collect::<Vec<_>>();
949 let selected_ids = selected_nodes
950 .iter()
951 .map(|node| node.id.clone())
952 .collect::<BTreeSet<_>>();
953 let mut warnings = Vec::new();
954 let mut eligible_edges = edges
955 .iter()
956 .filter(|edge| selected_ids.contains(&edge.source) && selected_ids.contains(&edge.target))
957 .collect::<Vec<_>>();
958 for edge in edges {
959 if !node_index.contains_key(&edge.source) || !node_index.contains_key(&edge.target) {
960 warnings.push(warning(
961 "export.dangling_edge",
962 &format!(
963 "Edge `{}` was omitted because an endpoint is missing.",
964 edge.id.as_str()
965 ),
966 ));
967 }
968 for evidence_id in &edge.evidence {
969 if !evidence_index.contains_key(evidence_id) {
970 warnings.push(warning(
971 "export.evidence_not_observed",
972 &format!(
973 "Evidence `{}` referenced by edge `{}` was not observed.",
974 evidence_id.as_str(),
975 edge.id.as_str()
976 ),
977 ));
978 }
979 }
980 }
981 eligible_edges.sort_by(|left, right| left.id.cmp(&right.id));
982 let selected_edges = eligible_edges
983 .into_iter()
984 .take(request.max_edges)
985 .map(|edge| ExportEdge {
986 edge: edge.clone(),
987 evidence: evidence_for_edge(edge, &evidence_index),
988 })
989 .collect::<Vec<_>>();
990 warnings.sort_by(|left, right| {
991 left.code
992 .cmp(&right.code)
993 .then_with(|| left.message.cmp(&right.message))
994 });
995 warnings.dedup();
996 let content = match request.format {
997 ExportFormat::Json => render_json(&selected_nodes, &selected_edges)?,
998 ExportFormat::GraphMl => render_graphml(&selected_nodes, &selected_edges),
999 ExportFormat::Markdown => render_markdown(&selected_nodes, &selected_edges),
1000 };
1001 let omitted_nodes = nodes.len().saturating_sub(selected_nodes.len());
1002 let omitted_edges = edges.len().saturating_sub(selected_edges.len());
1003 Ok(ExportReport {
1004 schema_version: INTERFACE_SCHEMA_VERSION,
1005 result_version: INTERFACE_RESULT_VERSION,
1006 format: request.format,
1007 content,
1008 exported_nodes: selected_nodes.len(),
1009 exported_edges: selected_edges.len(),
1010 omitted_nodes,
1011 omitted_edges,
1012 truncated: omitted_nodes > 0 || omitted_edges > 0,
1013 warnings,
1014 })
1015}
1016
1017#[must_use]
1022pub fn doctor(request: &DoctorRequest) -> DoctorReport {
1023 let mut checks = Vec::new();
1024 append_schema_checks(&request.schema, &mut checks);
1025 append_integrity_checks(&request.integrity, &mut checks);
1026 append_freshness_checks(&request.freshness, &mut checks);
1027 append_provider_checks(&request.providers, &mut checks);
1028 append_config_checks(&request.config, &mut checks);
1029 checks.sort_by(|left, right| {
1030 left.category
1031 .cmp(&right.category)
1032 .then_with(|| left.name.cmp(&right.name))
1033 .then_with(|| left.summary.cmp(&right.summary))
1034 });
1035 let healthy_checks = count_status(&checks, DoctorStatus::Healthy);
1036 let degraded_checks = count_status(&checks, DoctorStatus::Degraded);
1037 let failed_checks = count_status(&checks, DoctorStatus::Failed);
1038 let unknown_checks = count_status(&checks, DoctorStatus::Unknown);
1039 let status = if failed_checks > 0 {
1040 DoctorStatus::Failed
1041 } else if unknown_checks > 0 {
1042 DoctorStatus::Unknown
1043 } else if degraded_checks > 0 {
1044 DoctorStatus::Degraded
1045 } else {
1046 DoctorStatus::Healthy
1047 };
1048 DoctorReport {
1049 schema_version: INTERFACE_SCHEMA_VERSION,
1050 result_version: INTERFACE_RESULT_VERSION,
1051 status,
1052 checks,
1053 complete: unknown_checks == 0,
1054 healthy_checks,
1055 degraded_checks,
1056 failed_checks,
1057 unknown_checks,
1058 }
1059}
1060
1061pub fn paginate<T: Clone>(
1068 items: &[T],
1069 offset: usize,
1070 limit: usize,
1071 summary: Summary,
1072) -> Result<Page<T>, InterfaceError> {
1073 validate_bound("page", limit, MAX_EXPORT_NODES)?;
1074 let start = offset.min(items.len());
1075 let page_items = items
1076 .iter()
1077 .skip(start)
1078 .take(limit)
1079 .cloned()
1080 .collect::<Vec<_>>();
1081 let returned = page_items.len();
1082 Ok(Page {
1083 schema_version: INTERFACE_SCHEMA_VERSION,
1084 result_version: INTERFACE_RESULT_VERSION,
1085 items: page_items,
1086 pagination: Pagination {
1087 version: DELIVERY_METADATA_VERSION,
1088 offset,
1089 limit,
1090 total: items.len(),
1091 returned,
1092 has_more: start.saturating_add(returned) < items.len(),
1093 },
1094 summary,
1095 ambiguities: Vec::new(),
1096 warnings: Vec::new(),
1097 next_actions: Vec::new(),
1098 })
1099}
1100
1101pub fn public_schema_catalog() -> Result<PublicSchemaCatalog, InterfaceError> {
1107 let mut schemas = vec![
1108 public_schema::<ChangeImpactReport>("ChangeImpactReport")?,
1109 public_schema::<ChangeRequest>("ChangeRequest")?,
1110 public_schema::<ContractReport>("ContractReport")?,
1111 public_schema::<ContractRequest>("ContractRequest")?,
1112 public_schema::<DoctorReport>("DoctorReport")?,
1113 public_schema::<DoctorRequest>("DoctorRequest")?,
1114 public_schema::<ExportReport>("ExportReport")?,
1115 public_schema::<ExportRequest>("ExportRequest")?,
1116 public_schema::<ImpactReport>("ImpactReport")?,
1117 public_schema::<ImpactRequest>("ImpactRequest")?,
1118 public_schema::<Page<ContractView>>("PageContractView")?,
1119 public_schema::<PullRequestInspection>("PullRequestInspection")?,
1120 public_schema::<SearchReport>("SearchReport")?,
1121 public_schema::<SearchRequest>("SearchRequest")?,
1122 public_schema::<TraversalReport>("TraversalReport")?,
1123 public_schema::<TraversalRequest>("TraversalRequest")?,
1124 ];
1125 schemas.sort_by(|left, right| left.name.cmp(&right.name));
1126 Ok(PublicSchemaCatalog {
1127 schema_version: INTERFACE_SCHEMA_VERSION,
1128 result_version: INTERFACE_RESULT_VERSION,
1129 schemas,
1130 })
1131}
1132
1133#[must_use]
1135pub const fn classify_exit_code(kind: DomainErrorKind) -> ExitCode {
1136 match kind {
1137 DomainErrorKind::InvalidInput => ExitCode::InvalidInput,
1138 DomainErrorKind::NotFound => ExitCode::NotFound,
1139 DomainErrorKind::Ambiguous => ExitCode::Ambiguous,
1140 DomainErrorKind::Conflict => ExitCode::Conflict,
1141 DomainErrorKind::Partial => ExitCode::Partial,
1142 DomainErrorKind::Unavailable => ExitCode::Unavailable,
1143 DomainErrorKind::Timeout => ExitCode::Timeout,
1144 DomainErrorKind::Cancelled => ExitCode::Cancelled,
1145 DomainErrorKind::Internal => ExitCode::Internal,
1146 }
1147}
1148
1149#[must_use]
1151pub const fn classify_interface_error(error: &InterfaceError) -> ExitCode {
1152 match error {
1153 InterfaceError::MissingContractField { .. } | InterfaceError::InvalidBound { .. } => {
1154 ExitCode::InvalidInput
1155 }
1156 InterfaceError::NotFound(_) => ExitCode::NotFound,
1157 InterfaceError::DuplicateIdentity { .. } => ExitCode::Conflict,
1158 InterfaceError::Serialization(_) => ExitCode::Internal,
1159 }
1160}
1161
1162#[derive(Debug, Clone, PartialEq, Serialize)]
1163struct ExportEdge {
1164 edge: Edge,
1165 evidence: Vec<EvidenceMetadata>,
1166}
1167
1168#[derive(Serialize)]
1169struct JsonExport<'a> {
1170 schema_version: u32,
1171 result_version: u32,
1172 nodes: &'a [Node],
1173 edges: &'a [ExportEdge],
1174}
1175
1176fn validate_bound(name: &str, value: usize, maximum: usize) -> Result<(), InterfaceError> {
1177 if value == 0 || value > maximum {
1178 Err(InterfaceError::InvalidBound {
1179 name: name.to_owned(),
1180 value,
1181 maximum,
1182 })
1183 } else {
1184 Ok(())
1185 }
1186}
1187
1188fn index_nodes(nodes: &[Node]) -> Result<BTreeMap<NodeId, &Node>, InterfaceError> {
1189 let mut index = BTreeMap::new();
1190 for node in nodes {
1191 if index.insert(node.id.clone(), node).is_some() {
1192 return Err(InterfaceError::DuplicateIdentity {
1193 entity: "node".to_owned(),
1194 id: node.id.as_str().to_owned(),
1195 });
1196 }
1197 }
1198 Ok(index)
1199}
1200
1201fn index_edges(edges: &[Edge]) -> Result<BTreeMap<EdgeId, &Edge>, InterfaceError> {
1202 let mut index = BTreeMap::new();
1203 for edge in edges {
1204 if index.insert(edge.id.clone(), edge).is_some() {
1205 return Err(InterfaceError::DuplicateIdentity {
1206 entity: "edge".to_owned(),
1207 id: edge.id.as_str().to_owned(),
1208 });
1209 }
1210 }
1211 Ok(index)
1212}
1213
1214fn index_evidence(
1215 evidence: &[Evidence],
1216) -> Result<BTreeMap<EvidenceId, &Evidence>, InterfaceError> {
1217 let mut index = BTreeMap::new();
1218 for item in evidence {
1219 if index.insert(item.id.clone(), item).is_some() {
1220 return Err(InterfaceError::DuplicateIdentity {
1221 entity: "evidence".to_owned(),
1222 id: item.id.as_str().to_owned(),
1223 });
1224 }
1225 }
1226 Ok(index)
1227}
1228
1229const fn is_contract_kind(kind: NodeKind) -> bool {
1230 matches!(
1231 kind,
1232 NodeKind::Package
1233 | NodeKind::HttpOperation
1234 | NodeKind::GraphqlOperation
1235 | NodeKind::RpcMethod
1236 | NodeKind::EventChannel
1237 | NodeKind::EventSchema
1238 | NodeKind::Database
1239 | NodeKind::DatabaseTable
1240 | NodeKind::DatabaseColumn
1241 )
1242}
1243
1244fn ordered_contracts<'a>(nodes: &'a BTreeMap<NodeId, &Node>) -> Vec<&'a Node> {
1245 let mut contracts = nodes
1246 .values()
1247 .copied()
1248 .filter(|node| is_contract_kind(node.kind))
1249 .collect::<Vec<_>>();
1250 contracts.sort_by(|left, right| {
1251 left.stable_key
1252 .cmp(&right.stable_key)
1253 .then_with(|| left.id.cmp(&right.id))
1254 });
1255 contracts
1256}
1257
1258fn required_contract(request: &ContractRequest, related: bool) -> Result<&NodeId, InterfaceError> {
1259 let value = if related {
1260 request.related_contract.as_ref()
1261 } else {
1262 request.contract.as_ref()
1263 };
1264 value.ok_or_else(|| InterfaceError::MissingContractField {
1265 action: request.action,
1266 field: if related {
1267 "related_contract".to_owned()
1268 } else {
1269 "contract".to_owned()
1270 },
1271 })
1272}
1273
1274fn require_contract<'a>(
1275 nodes: &'a BTreeMap<NodeId, &Node>,
1276 contracts: &BTreeSet<NodeId>,
1277 id: &NodeId,
1278) -> Result<&'a Node, InterfaceError> {
1279 if !contracts.contains(id) {
1280 return Err(InterfaceError::NotFound(id.as_str().to_owned()));
1281 }
1282 nodes
1283 .get(id)
1284 .copied()
1285 .ok_or_else(|| InterfaceError::NotFound(id.as_str().to_owned()))
1286}
1287
1288fn validate_contract_inputs(
1289 nodes: &BTreeMap<NodeId, &Node>,
1290 edges: &BTreeMap<EdgeId, &Edge>,
1291 evidence: &BTreeMap<EvidenceId, &Evidence>,
1292) -> Vec<ContractIssue> {
1293 let mut issues = Vec::new();
1294 for edge in edges.values().copied() {
1295 for endpoint in [&edge.source, &edge.target] {
1296 if !nodes.contains_key(endpoint) {
1297 issues.push(contract_issue(
1298 "contract.dangling_edge",
1299 ContractIssueSeverity::Error,
1300 Some(edge.id.as_str()),
1301 &format!(
1302 "Edge `{}` references missing node `{}`.",
1303 edge.id.as_str(),
1304 endpoint.as_str()
1305 ),
1306 ));
1307 }
1308 }
1309 if !edge.confidence.is_finite() || !(0.0..=1.0).contains(&edge.confidence) {
1310 issues.push(contract_issue(
1311 "contract.invalid_confidence",
1312 ContractIssueSeverity::Error,
1313 Some(edge.id.as_str()),
1314 "Edge confidence is not finite and within the inclusive range 0..=1.",
1315 ));
1316 }
1317 if edge.status != EpistemicStatus::Confirmed {
1318 issues.push(contract_issue(
1319 "contract.unresolved_link",
1320 ContractIssueSeverity::Unknown,
1321 Some(edge.id.as_str()),
1322 "The relationship is not confirmed and cannot prove a contract link.",
1323 ));
1324 }
1325 for evidence_id in &edge.evidence {
1326 if !evidence.contains_key(evidence_id) {
1327 issues.push(contract_issue(
1328 "contract.evidence_not_observed",
1329 ContractIssueSeverity::Unknown,
1330 Some(edge.id.as_str()),
1331 &format!(
1332 "Referenced evidence `{}` was not observed.",
1333 evidence_id.as_str()
1334 ),
1335 ));
1336 }
1337 }
1338 }
1339 for item in evidence.values().copied() {
1340 if !item.confidence.is_finite() || !(0.0..=1.0).contains(&item.confidence) {
1341 issues.push(contract_issue(
1342 "contract.invalid_evidence_confidence",
1343 ContractIssueSeverity::Error,
1344 Some(item.id.as_str()),
1345 "Evidence confidence is not finite and within the inclusive range 0..=1.",
1346 ));
1347 }
1348 }
1349 issues
1350}
1351
1352fn contract_issue(
1353 code: &str,
1354 severity: ContractIssueSeverity,
1355 entity_id: Option<&str>,
1356 message: &str,
1357) -> ContractIssue {
1358 ContractIssue {
1359 code: code.to_owned(),
1360 severity,
1361 entity_id: entity_id.map(str::to_owned),
1362 message: message.to_owned(),
1363 }
1364}
1365
1366fn issue_order(left: &ContractIssue, right: &ContractIssue) -> std::cmp::Ordering {
1367 left.severity
1368 .cmp(&right.severity)
1369 .then_with(|| left.code.cmp(&right.code))
1370 .then_with(|| left.entity_id.cmp(&right.entity_id))
1371 .then_with(|| left.message.cmp(&right.message))
1372}
1373
1374fn issue_applies_to(
1375 issue: &ContractIssue,
1376 contract_id: &NodeId,
1377 edges: &BTreeMap<EdgeId, &Edge>,
1378) -> bool {
1379 issue.entity_id.as_deref().is_some_and(|entity_id| {
1380 entity_id == contract_id.as_str()
1381 || edges
1382 .get(&EdgeId::new(entity_id))
1383 .is_some_and(|edge| edge.source == *contract_id || edge.target == *contract_id)
1384 })
1385}
1386
1387fn evidence_for_edge(
1388 edge: &Edge,
1389 evidence: &BTreeMap<EvidenceId, &Evidence>,
1390) -> Vec<EvidenceMetadata> {
1391 let mut selected = edge
1392 .evidence
1393 .iter()
1394 .filter_map(|id| evidence.get(id).copied())
1395 .map(EvidenceMetadata::from)
1396 .collect::<Vec<_>>();
1397 selected.sort_by(|left, right| left.id.cmp(&right.id));
1398 selected
1399}
1400
1401fn link_from_edge(edge: &Edge, evidence: &BTreeMap<EvidenceId, &Evidence>) -> ContractLink {
1402 ContractLink {
1403 edge: edge.clone(),
1404 evidence: evidence_for_edge(edge, evidence),
1405 }
1406}
1407
1408fn links_for_node(
1409 id: &NodeId,
1410 edges: &BTreeMap<EdgeId, &Edge>,
1411 evidence: &BTreeMap<EvidenceId, &Evidence>,
1412 limit: usize,
1413) -> (Vec<ContractLink>, bool) {
1414 let matching = edges
1415 .values()
1416 .copied()
1417 .filter(|edge| edge.source == *id || edge.target == *id)
1418 .collect::<Vec<_>>();
1419 let truncated = matching.len() > limit;
1420 (
1421 matching
1422 .into_iter()
1423 .take(limit)
1424 .map(|edge| link_from_edge(edge, evidence))
1425 .collect(),
1426 truncated,
1427 )
1428}
1429
1430fn compatibility_for(
1431 ids: &[&NodeId],
1432 compatibility: &[ContractCompatibility],
1433 issues: &mut Vec<ContractIssue>,
1434 complete: &mut bool,
1435) -> Vec<ContractCompatibilitySummary> {
1436 let wanted = ids.iter().copied().collect::<BTreeSet<_>>();
1437 let mut selected = compatibility
1438 .iter()
1439 .filter(|item| wanted.contains(&item.contract_id))
1440 .map(compatibility_summary)
1441 .collect::<Vec<_>>();
1442 selected.sort_by(|left, right| left.contract_id.cmp(&right.contract_id));
1443 selected.dedup_by(|left, right| left.contract_id == right.contract_id);
1444 for id in ids {
1445 if !selected.iter().any(|item| &item.contract_id == *id) {
1446 *complete = false;
1447 issues.push(contract_issue(
1448 "contract.compatibility_not_observed",
1449 ContractIssueSeverity::Unknown,
1450 Some(id.as_str()),
1451 "Compatibility was not supplied for the selected contract.",
1452 ));
1453 }
1454 }
1455 selected
1456}
1457
1458fn compatibility_summary(value: &ContractCompatibility) -> ContractCompatibilitySummary {
1459 let mut findings = value
1460 .report
1461 .findings
1462 .iter()
1463 .map(|finding| ContractFinding {
1464 code: finding.code.clone(),
1465 path: finding.path.clone(),
1466 status: finding.status,
1467 factors: finding.factors.clone(),
1468 recommended_validations: finding.recommended_validations.clone(),
1469 })
1470 .collect::<Vec<_>>();
1471 findings.sort_by(|left, right| {
1472 left.path
1473 .cmp(&right.path)
1474 .then_with(|| left.code.cmp(&right.code))
1475 });
1476 ContractCompatibilitySummary {
1477 contract_id: value.contract_id.clone(),
1478 status: value.report.status,
1479 before_fingerprint: value.report.before_fingerprint.clone(),
1480 after_fingerprint: value.report.after_fingerprint.clone(),
1481 findings,
1482 }
1483}
1484
1485fn contract_differences(before: &Node, after: &Node) -> Vec<ContractDifference> {
1486 let mut differences = Vec::new();
1487 if before.kind != after.kind {
1488 differences.push(ContractDifference {
1489 field: "kind".to_owned(),
1490 before: Some(enum_name(before.kind)),
1491 after: Some(enum_name(after.kind)),
1492 });
1493 }
1494 if before.repo_id != after.repo_id {
1495 differences.push(ContractDifference {
1496 field: "repo_id".to_owned(),
1497 before: before.repo_id.as_ref().map(|id| id.as_str().to_owned()),
1498 after: after.repo_id.as_ref().map(|id| id.as_str().to_owned()),
1499 });
1500 }
1501 if before.stable_key != after.stable_key {
1502 differences.push(ContractDifference {
1503 field: "stable_key".to_owned(),
1504 before: Some(before.stable_key.clone()),
1505 after: Some(after.stable_key.clone()),
1506 });
1507 }
1508 if before.label != after.label {
1509 differences.push(ContractDifference {
1510 field: "label".to_owned(),
1511 before: Some(before.label.clone()),
1512 after: Some(after.label.clone()),
1513 });
1514 }
1515 differences
1516}
1517
1518fn render_json(nodes: &[Node], edges: &[ExportEdge]) -> Result<String, InterfaceError> {
1519 serde_json::to_string_pretty(&JsonExport {
1520 schema_version: INTERFACE_SCHEMA_VERSION,
1521 result_version: INTERFACE_RESULT_VERSION,
1522 nodes,
1523 edges,
1524 })
1525 .map_err(|_| InterfaceError::Serialization("graph export".to_owned()))
1526}
1527
1528fn render_graphml(nodes: &[Node], edges: &[ExportEdge]) -> String {
1529 let mut output = String::from(
1530 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
1531 <graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">\n\
1532 <key id=\"kind\" for=\"all\" attr.name=\"kind\" attr.type=\"string\"/>\n\
1533 <key id=\"stable_key\" for=\"node\" attr.name=\"stable_key\" attr.type=\"string\"/>\n\
1534 <key id=\"label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>\n\
1535 <key id=\"status\" for=\"edge\" attr.name=\"status\" attr.type=\"string\"/>\n\
1536 <key id=\"confidence\" for=\"edge\" attr.name=\"confidence\" attr.type=\"float\"/>\n\
1537 <key id=\"evidence\" for=\"edge\" attr.name=\"evidence\" attr.type=\"string\"/>\n\
1538 <graph id=\"code-system-graph\" edgedefault=\"directed\">\n",
1539 );
1540 for node in nodes {
1541 let _ = writeln!(
1542 output,
1543 "<node id=\"{}\"><data key=\"kind\">{}</data><data key=\"stable_key\">{}</data><data key=\"label\">{}</data></node>",
1544 escape_xml(node.id.as_str()),
1545 escape_xml(&enum_name(node.kind)),
1546 escape_xml(&node.stable_key),
1547 escape_xml(&node.label)
1548 );
1549 }
1550 for item in edges {
1551 let locations = item
1552 .evidence
1553 .iter()
1554 .map(evidence_location)
1555 .collect::<Vec<_>>()
1556 .join("; ");
1557 let _ = writeln!(
1558 output,
1559 "<edge id=\"{}\" source=\"{}\" target=\"{}\"><data key=\"kind\">{}</data><data key=\"status\">{}</data><data key=\"confidence\">{}</data><data key=\"evidence\">{}</data></edge>",
1560 escape_xml(item.edge.id.as_str()),
1561 escape_xml(item.edge.source.as_str()),
1562 escape_xml(item.edge.target.as_str()),
1563 escape_xml(&enum_name(item.edge.kind)),
1564 escape_xml(&enum_name(item.edge.status)),
1565 item.edge.confidence,
1566 escape_xml(&locations)
1567 );
1568 }
1569 output.push_str("</graph>\n</graphml>\n");
1570 output
1571}
1572
1573fn render_markdown(nodes: &[Node], edges: &[ExportEdge]) -> String {
1574 let mut output = String::from(
1575 "# Code System Graph graph export\n\n## Nodes\n\n| ID | Kind | Stable key | Label |\n|---|---|---|---|\n",
1576 );
1577 for node in nodes {
1578 let _ = writeln!(
1579 output,
1580 "| {} | {} | {} | {} |",
1581 escape_markdown(node.id.as_str()),
1582 escape_markdown(&enum_name(node.kind)),
1583 escape_markdown(&node.stable_key),
1584 escape_markdown(&node.label)
1585 );
1586 }
1587 output.push_str("\n## Edges\n\n| ID | Source | Target | Kind | Status | Confidence | Evidence |\n|---|---|---|---|---|---:|---|\n");
1588 for item in edges {
1589 let locations = item
1590 .evidence
1591 .iter()
1592 .map(evidence_location)
1593 .collect::<Vec<_>>()
1594 .join("; ");
1595 let _ = writeln!(
1596 output,
1597 "| {} | {} | {} | {} | {} | {} | {} |",
1598 escape_markdown(item.edge.id.as_str()),
1599 escape_markdown(item.edge.source.as_str()),
1600 escape_markdown(item.edge.target.as_str()),
1601 escape_markdown(&enum_name(item.edge.kind)),
1602 escape_markdown(&enum_name(item.edge.status)),
1603 item.edge.confidence,
1604 escape_markdown(&locations)
1605 );
1606 }
1607 output
1608}
1609
1610fn evidence_location(evidence: &EvidenceMetadata) -> String {
1611 let path = evidence.file_path.as_deref().unwrap_or("<unknown>");
1612 match (evidence.start_line, evidence.end_line) {
1613 (Some(start), Some(end)) => format!("{path}:{start}-{end}"),
1614 (Some(start), None) => format!("{path}:{start}"),
1615 _ => path.to_owned(),
1616 }
1617}
1618
1619fn enum_name<T: Serialize>(value: T) -> String {
1620 serde_json::to_value(value)
1621 .ok()
1622 .and_then(|value| value.as_str().map(str::to_owned))
1623 .unwrap_or_else(|| "unknown".to_owned())
1624}
1625
1626fn escape_xml(value: &str) -> String {
1627 let mut escaped = String::with_capacity(value.len());
1628 for character in value.chars() {
1629 match character {
1630 '&' => escaped.push_str("&"),
1631 '<' => escaped.push_str("<"),
1632 '>' => escaped.push_str(">"),
1633 '"' => escaped.push_str("""),
1634 '\'' => escaped.push_str("'"),
1635 '\t' | '\n' | '\r' => escaped.push(character),
1636 character if character.is_control() => escaped.push('\u{fffd}'),
1637 character => escaped.push(character),
1638 }
1639 }
1640 escaped
1641}
1642
1643fn escape_markdown(value: &str) -> String {
1644 let mut escaped = String::with_capacity(value.len());
1645 for character in value.chars() {
1646 match character {
1647 '\r' | '\n' | '\t' => escaped.push(' '),
1648 character if character.is_control() => escaped.push('\u{fffd}'),
1649 '\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '.'
1650 | '!' | '|' | '>' => {
1651 escaped.push('\\');
1652 escaped.push(character);
1653 }
1654 character => escaped.push(character),
1655 }
1656 }
1657 escaped
1658}
1659
1660fn append_schema_checks(inputs: &[SchemaDoctorInput], checks: &mut Vec<DoctorCheck>) {
1661 if inputs.is_empty() {
1662 checks.push(missing_doctor_category(DoctorCategory::Schema));
1663 }
1664 for input in inputs {
1665 let (status, summary, remediation) = match (input.actual_version, input.metadata_consistent)
1666 {
1667 (Some(actual), Some(true)) if actual == input.expected_version => (
1668 DoctorStatus::Healthy,
1669 format!("Schema version {actual} and metadata are consistent."),
1670 None,
1671 ),
1672 (Some(actual), _) if actual != input.expected_version => (
1673 DoctorStatus::Failed,
1674 format!(
1675 "Schema version {actual} does not match expected version {}.",
1676 input.expected_version
1677 ),
1678 Some("Remove the incompatible local database and run a full scan.".to_owned()),
1679 ),
1680 (Some(_), Some(false)) => (
1681 DoctorStatus::Failed,
1682 "Schema metadata is inconsistent.".to_owned(),
1683 Some("Restore an exact 1.0.0 backup or rebuild the local database.".to_owned()),
1684 ),
1685 _ => (
1686 DoctorStatus::Unknown,
1687 "Schema state was not fully observed.".to_owned(),
1688 Some("Open the store and validate its exact schema metadata.".to_owned()),
1689 ),
1690 };
1691 checks.push(DoctorCheck {
1692 category: DoctorCategory::Schema,
1693 name: input.name.clone(),
1694 status,
1695 summary,
1696 remediation,
1697 });
1698 }
1699}
1700
1701fn append_integrity_checks(inputs: &[IntegrityDoctorInput], checks: &mut Vec<DoctorCheck>) {
1702 if inputs.is_empty() {
1703 checks.push(missing_doctor_category(DoctorCategory::Integrity));
1704 }
1705 for input in inputs {
1706 let (status, fallback, remediation) = match input.passed {
1707 Some(true) => (DoctorStatus::Healthy, "Integrity check passed.", None),
1708 Some(false) => (
1709 DoctorStatus::Failed,
1710 "Integrity check failed.",
1711 Some("Restore the last verified backup before rebuilding state.".to_owned()),
1712 ),
1713 None => (
1714 DoctorStatus::Unknown,
1715 "Integrity check did not complete.",
1716 Some("Run the bounded integrity check before trusting stored results.".to_owned()),
1717 ),
1718 };
1719 checks.push(DoctorCheck {
1720 category: DoctorCategory::Integrity,
1721 name: input.name.clone(),
1722 status,
1723 summary: input.detail.clone().unwrap_or_else(|| fallback.to_owned()),
1724 remediation,
1725 });
1726 }
1727}
1728
1729fn append_freshness_checks(inputs: &[FreshnessDoctorInput], checks: &mut Vec<DoctorCheck>) {
1730 if inputs.is_empty() {
1731 checks.push(missing_doctor_category(DoctorCategory::Freshness));
1732 }
1733 for input in inputs {
1734 let (status, fallback, remediation) = match input.state {
1735 RepoFreshnessState::Fresh => (DoctorStatus::Healthy, "Snapshot is fresh.", None),
1736 RepoFreshnessState::WorkingTreeChanged
1737 | RepoFreshnessState::CommitsBehind
1738 | RepoFreshnessState::ConfigChanged
1739 | RepoFreshnessState::ExtractorChanged
1740 | RepoFreshnessState::CodegraphPending => (
1741 DoctorStatus::Degraded,
1742 "Snapshot is stale relative to a known input.",
1743 Some(
1744 "Run an explicit incremental scan before relying on safety claims.".to_owned(),
1745 ),
1746 ),
1747 RepoFreshnessState::Corrupt => (
1748 DoctorStatus::Failed,
1749 "Snapshot is marked corrupt.",
1750 Some(
1751 "Restore a verified snapshot or rebuild after integrity diagnosis.".to_owned(),
1752 ),
1753 ),
1754 RepoFreshnessState::Partial
1755 | RepoFreshnessState::Unknown
1756 | RepoFreshnessState::Unavailable => (
1757 DoctorStatus::Unknown,
1758 "Repository freshness is incomplete or unavailable.",
1759 Some("Restore repository access and complete a bounded scan.".to_owned()),
1760 ),
1761 };
1762 checks.push(DoctorCheck {
1763 category: DoctorCategory::Freshness,
1764 name: input.repo_id.as_str().to_owned(),
1765 status,
1766 summary: input.detail.clone().unwrap_or_else(|| fallback.to_owned()),
1767 remediation,
1768 });
1769 }
1770}
1771
1772fn append_provider_checks(inputs: &[ProviderDoctorInput], checks: &mut Vec<DoctorCheck>) {
1773 if inputs.is_empty() {
1774 checks.push(missing_doctor_category(DoctorCategory::Provider));
1775 }
1776 for input in inputs {
1777 let (status, fallback, remediation) = match input.status {
1778 ProviderDoctorStatus::Available => {
1779 (DoctorStatus::Healthy, "Provider is available.", None)
1780 }
1781 ProviderDoctorStatus::IndexMissing | ProviderDoctorStatus::Stale => (
1782 DoctorStatus::Degraded,
1783 "Provider index is missing or stale.",
1784 Some(
1785 "Initialize or refresh the provider index explicitly if enrichment is needed."
1786 .to_owned(),
1787 ),
1788 ),
1789 ProviderDoctorStatus::Unavailable | ProviderDoctorStatus::TimedOut => (
1790 DoctorStatus::Unknown,
1791 "Provider availability could not be established.",
1792 Some(
1793 "Verify the provider binary and rerun its bounded compatibility probe."
1794 .to_owned(),
1795 ),
1796 ),
1797 ProviderDoctorStatus::Incompatible | ProviderDoctorStatus::InvalidResponse => (
1798 DoctorStatus::Failed,
1799 "Provider public contract is incompatible or invalid.",
1800 Some(
1801 "Use a tested provider version or disable the incompatible adapter.".to_owned(),
1802 ),
1803 ),
1804 };
1805 checks.push(DoctorCheck {
1806 category: DoctorCategory::Provider,
1807 name: input.name.clone(),
1808 status,
1809 summary: input.detail.clone().unwrap_or_else(|| fallback.to_owned()),
1810 remediation,
1811 });
1812 }
1813}
1814
1815fn append_config_checks(inputs: &[ConfigDoctorInput], checks: &mut Vec<DoctorCheck>) {
1816 if inputs.is_empty() {
1817 checks.push(missing_doctor_category(DoctorCategory::Config));
1818 }
1819 for input in inputs {
1820 let (status, fallback, remediation) = match input.valid {
1821 Some(true) => (DoctorStatus::Healthy, "Configuration is valid.", None),
1822 Some(false) => (
1823 DoctorStatus::Failed,
1824 "Configuration is invalid.",
1825 Some(
1826 "Correct the reported field without discarding unrelated settings.".to_owned(),
1827 ),
1828 ),
1829 None => (
1830 DoctorStatus::Unknown,
1831 "Configuration validation did not complete.",
1832 Some("Load and validate every effective configuration layer.".to_owned()),
1833 ),
1834 };
1835 checks.push(DoctorCheck {
1836 category: DoctorCategory::Config,
1837 name: input.name.clone(),
1838 status,
1839 summary: input.detail.clone().unwrap_or_else(|| fallback.to_owned()),
1840 remediation,
1841 });
1842 }
1843}
1844
1845fn missing_doctor_category(category: DoctorCategory) -> DoctorCheck {
1846 DoctorCheck {
1847 category,
1848 name: "not_observed".to_owned(),
1849 status: DoctorStatus::Unknown,
1850 summary: "No input was supplied for this required doctor category.".to_owned(),
1851 remediation: Some(
1852 "Collect the category check before claiming a healthy system.".to_owned(),
1853 ),
1854 }
1855}
1856
1857fn count_status(checks: &[DoctorCheck], status: DoctorStatus) -> usize {
1858 checks.iter().filter(|check| check.status == status).count()
1859}
1860
1861fn warning(code: &str, message: &str) -> Warning {
1862 Warning {
1863 version: DELIVERY_METADATA_VERSION,
1864 code: code.to_owned(),
1865 message: message.to_owned(),
1866 }
1867}
1868
1869fn public_schema<T: JsonSchema>(name: &str) -> Result<PublicSchema, InterfaceError> {
1870 let schema = serde_json::to_value(schema_for!(T))
1871 .map_err(|_| InterfaceError::Serialization(format!("schema {name}")))?;
1872 Ok(PublicSchema {
1873 name: name.to_owned(),
1874 version: INTERFACE_SCHEMA_VERSION,
1875 schema,
1876 })
1877}
1878
1879#[cfg(test)]
1880mod tests {
1881 use code_system_graph_model::{EdgeKind, EvidenceId};
1882
1883 use super::*;
1884 use crate::{CompatibilityFinding, CompatibilityReport};
1885
1886 fn node(id: &str, kind: NodeKind, stable_key: &str) -> Node {
1887 Node {
1888 id: NodeId::new(id),
1889 kind,
1890 repo_id: Some(RepoId::new("repo:test")),
1891 stable_key: stable_key.to_owned(),
1892 label: stable_key.to_owned(),
1893 }
1894 }
1895
1896 fn evidence(id: &str, path: &str) -> Evidence {
1897 Evidence {
1898 id: EvidenceId::new(id),
1899 repo_id: Some(RepoId::new("repo:test")),
1900 file_path: Some(path.to_owned()),
1901 start_line: Some(3),
1902 end_line: Some(5),
1903 extractor: "test".to_owned(),
1904 extractor_version: "1.0.0".to_owned(),
1905 provenance: Provenance::Extracted,
1906 confidence: 1.0,
1907 observed_at_commit: Some("abc".to_owned()),
1908 content_hash: Some("hash".to_owned()),
1909 note: Some("LEAK_ME_SOURCE_BODY".to_owned()),
1910 }
1911 }
1912
1913 fn edge(id: &str, source: &str, target: &str, evidence: &[&str]) -> Edge {
1914 Edge {
1915 id: EdgeId::new(id),
1916 source: NodeId::new(source),
1917 target: NodeId::new(target),
1918 kind: EdgeKind::CallsRemote,
1919 confidence: 1.0,
1920 status: EpistemicStatus::Confirmed,
1921 evidence: evidence.iter().map(|id| EvidenceId::new(*id)).collect(),
1922 }
1923 }
1924
1925 fn request(action: ContractAction) -> ContractRequest {
1926 ContractRequest {
1927 action,
1928 contract: None,
1929 related_contract: None,
1930 limit: 100,
1931 }
1932 }
1933
1934 fn report_or_panic(result: Result<ContractReport, InterfaceError>) -> ContractReport {
1935 match result {
1936 Ok(report) => report,
1937 Err(error) => panic!("unexpected contract error: {error}"),
1938 }
1939 }
1940
1941 fn export_or_panic(result: Result<ExportReport, InterfaceError>) -> ExportReport {
1942 match result {
1943 Ok(report) => report,
1944 Err(error) => panic!("unexpected export error: {error}"),
1945 }
1946 }
1947
1948 fn json_or_panic<T: Serialize>(value: &T) -> String {
1949 match serde_json::to_string(value) {
1950 Ok(json) => json,
1951 Err(error) => panic!("unexpected JSON error: {error}"),
1952 }
1953 }
1954
1955 fn healthy_doctor_request() -> DoctorRequest {
1956 DoctorRequest {
1957 schema: vec![SchemaDoctorInput {
1958 name: "store".to_owned(),
1959 expected_version: 8,
1960 actual_version: Some(8),
1961 metadata_consistent: Some(true),
1962 }],
1963 integrity: vec![IntegrityDoctorInput {
1964 name: "sqlite".to_owned(),
1965 passed: Some(true),
1966 detail: None,
1967 }],
1968 freshness: vec![FreshnessDoctorInput {
1969 repo_id: RepoId::new("repo:test"),
1970 state: RepoFreshnessState::Fresh,
1971 detail: None,
1972 }],
1973 providers: vec![ProviderDoctorInput {
1974 name: "codegraph".to_owned(),
1975 status: ProviderDoctorStatus::Available,
1976 detail: None,
1977 }],
1978 config: vec![ConfigDoctorInput {
1979 name: "workspace".to_owned(),
1980 valid: Some(true),
1981 detail: None,
1982 }],
1983 }
1984 }
1985
1986 fn summary() -> Summary {
1987 Summary {
1988 version: DELIVERY_METADATA_VERSION,
1989 code: "test".to_owned(),
1990 title: "Test".to_owned(),
1991 detail: "Test page".to_owned(),
1992 complete: true,
1993 }
1994 }
1995
1996 #[test]
1997 fn contract_list_is_deterministic() {
1998 let nodes = vec![
1999 node("node:b", NodeKind::HttpOperation, "z"),
2000 node("node:a", NodeKind::RpcMethod, "a"),
2001 ];
2002 let first = report_or_panic(inspect_contracts(
2003 &nodes,
2004 &[],
2005 &[],
2006 &[],
2007 &request(ContractAction::List),
2008 ));
2009 let mut reversed = nodes;
2010 reversed.reverse();
2011 let second = report_or_panic(inspect_contracts(
2012 &reversed,
2013 &[],
2014 &[],
2015 &[],
2016 &request(ContractAction::List),
2017 ));
2018
2019 assert_eq!(first.contracts, second.contracts);
2020 }
2021
2022 #[test]
2023 fn contract_list_excludes_non_contract_nodes() {
2024 let nodes = vec![
2025 node("node:service", NodeKind::Service, "service"),
2026 node("node:http", NodeKind::HttpOperation, "http"),
2027 ];
2028 let report = report_or_panic(inspect_contracts(
2029 &nodes,
2030 &[],
2031 &[],
2032 &[],
2033 &request(ContractAction::List),
2034 ));
2035
2036 assert_eq!(report.contracts.len(), 1);
2037 }
2038
2039 #[test]
2040 fn contract_list_applies_limit() {
2041 let nodes = vec![
2042 node("node:a", NodeKind::HttpOperation, "a"),
2043 node("node:b", NodeKind::HttpOperation, "b"),
2044 ];
2045 let mut input = request(ContractAction::List);
2046 input.limit = 1;
2047 let report = report_or_panic(inspect_contracts(&nodes, &[], &[], &[], &input));
2048
2049 assert!(report.truncated && report.contracts.len() == 1);
2050 }
2051
2052 #[test]
2053 fn contract_show_omits_evidence_note() {
2054 let nodes = vec![
2055 node("node:a", NodeKind::HttpOperation, "a"),
2056 node("node:b", NodeKind::HttpOperation, "b"),
2057 ];
2058 let edges = vec![edge("edge:1", "node:a", "node:b", &["evidence:1"])];
2059 let evidence = vec![evidence("evidence:1", "src/<api>.rs")];
2060 let mut input = request(ContractAction::Show);
2061 input.contract = Some(NodeId::new("node:a"));
2062 let report = report_or_panic(inspect_contracts(&nodes, &edges, &evidence, &[], &input));
2063
2064 assert!(!json_or_panic(&report).contains("LEAK_ME_SOURCE_BODY"));
2065 }
2066
2067 #[test]
2068 fn contract_show_rejects_unknown_contract() {
2069 let mut input = request(ContractAction::Show);
2070 input.contract = Some(NodeId::new("node:missing"));
2071 let result = inspect_contracts(&[], &[], &[], &[], &input);
2072
2073 assert!(matches!(result, Err(InterfaceError::NotFound(_))));
2074 }
2075
2076 #[test]
2077 fn contract_show_requires_contract_field() {
2078 let result = inspect_contracts(&[], &[], &[], &[], &request(ContractAction::Show));
2079
2080 assert!(matches!(
2081 result,
2082 Err(InterfaceError::MissingContractField { .. })
2083 ));
2084 }
2085
2086 #[test]
2087 fn contract_validate_detects_dangling_edge() {
2088 let nodes = vec![node("node:a", NodeKind::HttpOperation, "a")];
2089 let edges = vec![edge("edge:1", "node:a", "node:missing", &[])];
2090 let report = report_or_panic(inspect_contracts(
2091 &nodes,
2092 &edges,
2093 &[],
2094 &[],
2095 &request(ContractAction::Validate),
2096 ));
2097
2098 assert_eq!(report.valid, Some(false));
2099 }
2100
2101 #[test]
2102 fn contract_validate_preserves_unknown_evidence() {
2103 let nodes = vec![
2104 node("node:a", NodeKind::HttpOperation, "a"),
2105 node("node:b", NodeKind::HttpOperation, "b"),
2106 ];
2107 let edges = vec![edge("edge:1", "node:a", "node:b", &["evidence:missing"])];
2108 let report = report_or_panic(inspect_contracts(
2109 &nodes,
2110 &edges,
2111 &[],
2112 &[],
2113 &request(ContractAction::Validate),
2114 ));
2115
2116 assert_eq!(report.valid, Some(false));
2117 }
2118
2119 #[test]
2120 fn contract_diff_omits_compatibility_evidence_text() {
2121 let nodes = vec![
2122 node("node:a", NodeKind::HttpOperation, "a"),
2123 node("node:b", NodeKind::HttpOperation, "b"),
2124 ];
2125 let compatibility = vec![ContractCompatibility {
2126 contract_id: NodeId::new("node:a"),
2127 report: CompatibilityReport {
2128 status: CompatibilityStatus::Breaking,
2129 before_fingerprint: "before".to_owned(),
2130 after_fingerprint: "after".to_owned(),
2131 findings: vec![CompatibilityFinding {
2132 code: "removed".to_owned(),
2133 path: "GET /a".to_owned(),
2134 status: CompatibilityStatus::Breaking,
2135 factors: vec!["operation removed".to_owned()],
2136 evidence: vec!["LEAK_ME_SOURCE_BODY".to_owned()],
2137 recommended_validations: vec!["run contract tests".to_owned()],
2138 }],
2139 },
2140 }];
2141 let mut input = request(ContractAction::Diff);
2142 input.contract = Some(NodeId::new("node:a"));
2143 input.related_contract = Some(NodeId::new("node:b"));
2144 let report = report_or_panic(inspect_contracts(&nodes, &[], &[], &compatibility, &input));
2145
2146 assert!(!json_or_panic(&report).contains("LEAK_ME_SOURCE_BODY"));
2147 }
2148
2149 #[test]
2150 fn contract_diff_reports_structural_fields() {
2151 let nodes = vec![
2152 node("node:a", NodeKind::HttpOperation, "a"),
2153 node("node:b", NodeKind::RpcMethod, "b"),
2154 ];
2155 let mut input = request(ContractAction::Diff);
2156 input.contract = Some(NodeId::new("node:a"));
2157 input.related_contract = Some(NodeId::new("node:b"));
2158 let report = report_or_panic(inspect_contracts(&nodes, &[], &[], &[], &input));
2159
2160 assert!(report.differences.iter().any(|item| item.field == "kind"));
2161 }
2162
2163 #[test]
2164 fn explain_link_returns_direct_edge() {
2165 let nodes = vec![
2166 node("node:a", NodeKind::HttpOperation, "a"),
2167 node("node:b", NodeKind::RpcMethod, "b"),
2168 ];
2169 let edges = vec![edge("edge:1", "node:a", "node:b", &[])];
2170 let mut input = request(ContractAction::ExplainLink);
2171 input.contract = Some(NodeId::new("node:a"));
2172 input.related_contract = Some(NodeId::new("node:b"));
2173 let report = report_or_panic(inspect_contracts(&nodes, &edges, &[], &[], &input));
2174
2175 assert_eq!(report.links.len(), 1);
2176 }
2177
2178 #[test]
2179 fn explain_link_marks_absence_unknown() {
2180 let nodes = vec![
2181 node("node:a", NodeKind::HttpOperation, "a"),
2182 node("node:b", NodeKind::RpcMethod, "b"),
2183 ];
2184 let mut input = request(ContractAction::ExplainLink);
2185 input.contract = Some(NodeId::new("node:a"));
2186 input.related_contract = Some(NodeId::new("node:b"));
2187 let report = report_or_panic(inspect_contracts(&nodes, &[], &[], &[], &input));
2188
2189 assert!(
2190 !report.complete
2191 && report
2192 .issues
2193 .iter()
2194 .any(|issue| issue.severity == ContractIssueSeverity::Unknown)
2195 );
2196 }
2197
2198 #[test]
2199 fn json_export_is_deterministic() {
2200 let nodes = vec![
2201 node("node:b", NodeKind::HttpOperation, "b"),
2202 node("node:a", NodeKind::HttpOperation, "a"),
2203 ];
2204 let first = export_or_panic(export_graph(&nodes, &[], &[], &ExportRequest::default()));
2205 let mut reversed = nodes;
2206 reversed.reverse();
2207 let second = export_or_panic(export_graph(&reversed, &[], &[], &ExportRequest::default()));
2208
2209 assert_eq!(first.content, second.content);
2210 }
2211
2212 #[test]
2213 fn json_export_omits_evidence_note() {
2214 let nodes = vec![
2215 node("node:a", NodeKind::HttpOperation, "a"),
2216 node("node:b", NodeKind::HttpOperation, "b"),
2217 ];
2218 let edges = vec![edge("edge:1", "node:a", "node:b", &["evidence:1"])];
2219 let report = export_or_panic(export_graph(
2220 &nodes,
2221 &edges,
2222 &[evidence("evidence:1", "api.rs")],
2223 &ExportRequest::default(),
2224 ));
2225
2226 assert!(!report.content.contains("LEAK_ME_SOURCE_BODY"));
2227 }
2228
2229 #[test]
2230 fn export_applies_node_bound() {
2231 let nodes = vec![
2232 node("node:a", NodeKind::HttpOperation, "a"),
2233 node("node:b", NodeKind::HttpOperation, "b"),
2234 ];
2235 let report = export_or_panic(export_graph(
2236 &nodes,
2237 &[],
2238 &[],
2239 &ExportRequest {
2240 max_nodes: 1,
2241 ..ExportRequest::default()
2242 },
2243 ));
2244
2245 assert!(report.truncated && report.exported_nodes == 1);
2246 }
2247
2248 #[test]
2249 fn export_applies_edge_bound() {
2250 let nodes = vec![
2251 node("node:a", NodeKind::HttpOperation, "a"),
2252 node("node:b", NodeKind::HttpOperation, "b"),
2253 ];
2254 let edges = vec![
2255 edge("edge:1", "node:a", "node:b", &[]),
2256 edge("edge:2", "node:b", "node:a", &[]),
2257 ];
2258 let report = export_or_panic(export_graph(
2259 &nodes,
2260 &edges,
2261 &[],
2262 &ExportRequest {
2263 max_edges: 1,
2264 ..ExportRequest::default()
2265 },
2266 ));
2267
2268 assert!(report.truncated && report.exported_edges == 1);
2269 }
2270
2271 #[test]
2272 fn graphml_escapes_xml_text() {
2273 let nodes = vec![node(
2274 "node:<a>&\"'",
2275 NodeKind::HttpOperation,
2276 "<script>&\"'",
2277 )];
2278 let report = export_or_panic(export_graph(
2279 &nodes,
2280 &[],
2281 &[],
2282 &ExportRequest {
2283 format: ExportFormat::GraphMl,
2284 ..ExportRequest::default()
2285 },
2286 ));
2287
2288 assert!(
2289 report.content.contains("<script>&"'")
2290 && !report.content.contains("<script>")
2291 );
2292 }
2293
2294 #[test]
2295 fn markdown_escapes_table_and_markup_text() {
2296 let nodes = vec![node("node:a", NodeKind::HttpOperation, "a|**unsafe**\nrow")];
2297 let report = export_or_panic(export_graph(
2298 &nodes,
2299 &[],
2300 &[],
2301 &ExportRequest {
2302 format: ExportFormat::Markdown,
2303 ..ExportRequest::default()
2304 },
2305 ));
2306
2307 assert!(report.content.contains(r"a\|\*\*unsafe\*\* row"));
2308 }
2309
2310 #[test]
2311 fn export_rejects_zero_bound() {
2312 let result = export_graph(
2313 &[],
2314 &[],
2315 &[],
2316 &ExportRequest {
2317 max_nodes: 0,
2318 ..ExportRequest::default()
2319 },
2320 );
2321
2322 assert!(matches!(result, Err(InterfaceError::InvalidBound { .. })));
2323 }
2324
2325 #[test]
2326 fn export_warns_and_omits_dangling_edge() {
2327 let nodes = vec![node("node:a", NodeKind::HttpOperation, "a")];
2328 let edges = vec![edge("edge:1", "node:a", "node:missing", &[])];
2329 let report = export_or_panic(export_graph(&nodes, &edges, &[], &ExportRequest::default()));
2330
2331 assert!(report.exported_edges == 0 && !report.warnings.is_empty());
2332 }
2333
2334 #[test]
2335 fn doctor_reports_healthy_complete_input() {
2336 let report = doctor(&healthy_doctor_request());
2337
2338 assert!(report.status == DoctorStatus::Healthy && report.complete);
2339 }
2340
2341 #[test]
2342 fn doctor_failed_check_dominates_unknown() {
2343 let mut input = DoctorRequest::default();
2344 input.config.push(ConfigDoctorInput {
2345 name: "workspace".to_owned(),
2346 valid: Some(false),
2347 detail: None,
2348 });
2349 let report = doctor(&input);
2350
2351 assert_eq!(report.status, DoctorStatus::Failed);
2352 }
2353
2354 #[test]
2355 fn doctor_unknown_dominates_degraded() {
2356 let mut input = healthy_doctor_request();
2357 input.providers[0].status = ProviderDoctorStatus::Stale;
2358 input.integrity[0].passed = None;
2359 let report = doctor(&input);
2360
2361 assert_eq!(report.status, DoctorStatus::Unknown);
2362 }
2363
2364 #[test]
2365 fn doctor_partial_freshness_is_unknown() {
2366 let mut input = healthy_doctor_request();
2367 input.freshness[0].state = RepoFreshnessState::Partial;
2368 let report = doctor(&input);
2369
2370 assert_eq!(report.status, DoctorStatus::Unknown);
2371 }
2372
2373 #[test]
2374 fn doctor_missing_categories_are_unknown() {
2375 let report = doctor(&DoctorRequest::default());
2376
2377 assert!(report.status == DoctorStatus::Unknown && report.unknown_checks == 5);
2378 }
2379
2380 #[test]
2381 fn doctor_check_order_is_deterministic() {
2382 let mut first_input = healthy_doctor_request();
2383 first_input.config.push(ConfigDoctorInput {
2384 name: "alpha".to_owned(),
2385 valid: Some(true),
2386 detail: None,
2387 });
2388 let mut second_input = first_input.clone();
2389 second_input.config.reverse();
2390 let first = doctor(&first_input);
2391 let second = doctor(&second_input);
2392
2393 assert_eq!(first, second);
2394 }
2395
2396 #[test]
2397 fn schema_catalog_is_deterministic() {
2398 let first = public_schema_catalog();
2399 let second = public_schema_catalog();
2400
2401 assert_eq!(first, second);
2402 }
2403
2404 #[test]
2405 fn schema_catalog_contains_core_interface_types() {
2406 let catalog = match public_schema_catalog() {
2407 Ok(catalog) => catalog,
2408 Err(error) => panic!("unexpected catalog error: {error}"),
2409 };
2410
2411 assert!(
2412 [
2413 "ContractRequest",
2414 "ContractReport",
2415 "ExportRequest",
2416 "DoctorReport"
2417 ]
2418 .iter()
2419 .all(|name| catalog.schemas.iter().any(|schema| schema.name == *name))
2420 );
2421 }
2422
2423 #[test]
2424 fn generated_request_schema_has_object_shape() {
2425 let catalog = match public_schema_catalog() {
2426 Ok(catalog) => catalog,
2427 Err(error) => panic!("unexpected catalog error: {error}"),
2428 };
2429 let request_schema = catalog
2430 .schemas
2431 .iter()
2432 .find(|schema| schema.name == "ContractRequest");
2433
2434 assert!(request_schema.is_some_and(|schema| schema.schema.get("properties").is_some()));
2435 }
2436
2437 #[test]
2438 fn pagination_has_no_duplicates_across_pages() {
2439 let items = vec![1, 2, 3, 4];
2440 let first = paginate(&items, 0, 2, summary());
2441 let second = paginate(&items, 2, 2, summary());
2442 let combined = first
2443 .ok()
2444 .into_iter()
2445 .flat_map(|page| page.items)
2446 .chain(second.ok().into_iter().flat_map(|page| page.items))
2447 .collect::<BTreeSet<_>>();
2448
2449 assert_eq!(combined, BTreeSet::from([1, 2, 3, 4]));
2450 }
2451
2452 #[test]
2453 fn pagination_reports_unknown_offset_without_panicking() {
2454 let page = match paginate(&[1, 2], 100, 10, summary()) {
2455 Ok(page) => page,
2456 Err(error) => panic!("unexpected pagination error: {error}"),
2457 };
2458
2459 assert!(page.items.is_empty() && !page.pagination.has_more);
2460 }
2461
2462 #[test]
2463 fn pagination_rejects_zero_limit() {
2464 let result = paginate(&[1], 0, 0, summary());
2465
2466 assert!(matches!(result, Err(InterfaceError::InvalidBound { .. })));
2467 }
2468
2469 #[test]
2470 fn exit_code_mapping_is_stable() {
2471 let values = [
2472 classify_exit_code(DomainErrorKind::InvalidInput).value(),
2473 classify_exit_code(DomainErrorKind::NotFound).value(),
2474 classify_exit_code(DomainErrorKind::Ambiguous).value(),
2475 classify_exit_code(DomainErrorKind::Conflict).value(),
2476 classify_exit_code(DomainErrorKind::Partial).value(),
2477 classify_exit_code(DomainErrorKind::Unavailable).value(),
2478 classify_exit_code(DomainErrorKind::Timeout).value(),
2479 classify_exit_code(DomainErrorKind::Internal).value(),
2480 classify_exit_code(DomainErrorKind::Cancelled).value(),
2481 ];
2482
2483 assert_eq!(values, [2, 3, 4, 5, 6, 7, 8, 70, 130]);
2484 }
2485
2486 #[test]
2487 fn contract_action_uses_stable_kebab_case() {
2488 assert_eq!(
2489 json_or_panic(&ContractAction::ExplainLink),
2490 "\"explain-link\""
2491 );
2492 }
2493}