Skip to main content

code_system_graph_core/
interfaces.rs

1//! Source-free application-layer contracts and deterministic renderers.
2
3use 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
17/// Schema version shared by the application-layer contracts in this module.
18pub const INTERFACE_SCHEMA_VERSION: u32 = 1;
19/// Result version shared by the application-layer reports in this module.
20pub const INTERFACE_RESULT_VERSION: u32 = 1;
21/// Version of reusable delivery metadata.
22pub const DELIVERY_METADATA_VERSION: u32 = 1;
23/// Hard maximum number of nodes accepted by one graph export.
24pub const MAX_EXPORT_NODES: usize = 100_000;
25/// Hard maximum number of edges accepted by one graph export.
26pub const MAX_EXPORT_EDGES: usize = 1_000_000;
27const DEFAULT_EXPORT_NODES: usize = 10_000;
28const DEFAULT_EXPORT_EDGES: usize = 50_000;
29
30/// Contract operation requested by a delivery adapter.
31#[derive(
32    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
33)]
34#[serde(rename_all = "kebab-case")]
35pub enum ContractAction {
36    /// List contract nodes in stable order.
37    List,
38    /// Show one contract and its directly observed relationships.
39    Show,
40    /// Validate graph and evidence integrity for all or one contract.
41    Validate,
42    /// Compare two contract nodes and surface supplied compatibility analysis.
43    Diff,
44    /// Explain direct links between two contract nodes.
45    ExplainLink,
46}
47
48/// Bounded input for one contract application operation.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50pub struct ContractRequest {
51    /// Requested operation.
52    pub action: ContractAction,
53    /// Primary contract for `show`, targeted `validate`, `diff`, or `explain-link`.
54    pub contract: Option<NodeId>,
55    /// Comparison or link endpoint required by `diff` and `explain-link`.
56    pub related_contract: Option<NodeId>,
57    /// Maximum number of contracts or links returned.
58    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/// Compatibility result associated with one exact graph contract.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74pub struct ContractCompatibility {
75    /// Contract node receiving the compatibility result.
76    pub contract_id: NodeId,
77    /// Existing conservative compatibility report.
78    pub report: CompatibilityReport,
79}
80
81/// Evidence location and provenance safe for public delivery.
82///
83/// Explanatory notes and source bodies are deliberately absent.
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
85pub struct EvidenceMetadata {
86    /// Stable evidence identifier.
87    pub id: EvidenceId,
88    /// Repository containing the observation.
89    pub repo_id: Option<RepoId>,
90    /// Repository-relative path, when available.
91    pub file_path: Option<String>,
92    /// Inclusive first observed line.
93    pub start_line: Option<u32>,
94    /// Inclusive last observed line.
95    pub end_line: Option<u32>,
96    /// Extractor identifier.
97    pub extractor: String,
98    /// Extractor semantic version.
99    pub extractor_version: String,
100    /// Observation provenance.
101    pub provenance: Provenance,
102    /// Normalized confidence.
103    pub confidence: f32,
104    /// Commit at which the location was observed.
105    pub observed_at_commit: Option<String>,
106    /// Hash of relevant content, never the content itself.
107    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/// Source-free relationship explanation.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
130pub struct ContractLink {
131    /// Exact graph relationship.
132    pub edge: Edge,
133    /// Deterministically ordered supporting evidence metadata.
134    pub evidence: Vec<EvidenceMetadata>,
135}
136
137/// Public projection of one contract node.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
139pub struct ContractView {
140    /// Exact source-free graph node.
141    pub contract: Node,
142    /// Direct incoming and outgoing relationships.
143    pub links: Vec<ContractLink>,
144}
145
146/// Compatibility finding with untrusted evidence text removed.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
148pub struct ContractFinding {
149    /// Stable compatibility rule code.
150    pub code: String,
151    /// Contract coordinate affected by the finding.
152    pub path: String,
153    /// Conservative classification.
154    pub status: CompatibilityStatus,
155    /// Bounded factors supplied by the compatibility engine.
156    pub factors: Vec<String>,
157    /// Non-executing recommended validations.
158    pub recommended_validations: Vec<String>,
159}
160
161/// Source-free compatibility projection for one contract.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
163pub struct ContractCompatibilitySummary {
164    /// Exact contract identifier.
165    pub contract_id: NodeId,
166    /// Aggregate conservative status.
167    pub status: CompatibilityStatus,
168    /// Fingerprint of the previous structured contract.
169    pub before_fingerprint: String,
170    /// Fingerprint of the candidate structured contract.
171    pub after_fingerprint: String,
172    /// Deterministically ordered findings without evidence text.
173    pub findings: Vec<ContractFinding>,
174}
175
176/// One structural difference between two contract nodes.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
178pub struct ContractDifference {
179    /// Stable field name.
180    pub field: String,
181    /// Previous source-free value.
182    pub before: Option<String>,
183    /// Candidate source-free value.
184    pub after: Option<String>,
185}
186
187/// Severity of a contract validation issue.
188#[derive(
189    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
190)]
191#[serde(rename_all = "snake_case")]
192pub enum ContractIssueSeverity {
193    /// Coverage is insufficient for a definitive result.
194    Unknown,
195    /// Input is usable but degraded.
196    Warning,
197    /// Input violates a graph or evidence invariant.
198    Error,
199}
200
201/// One stable contract validation or coverage issue.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
203pub struct ContractIssue {
204    /// Stable machine-readable code.
205    pub code: String,
206    /// Issue severity.
207    pub severity: ContractIssueSeverity,
208    /// Affected source-free entity identifier.
209    pub entity_id: Option<String>,
210    /// Bounded explanation.
211    pub message: String,
212}
213
214/// Result of one contract application operation.
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
216pub struct ContractReport {
217    /// Public schema version.
218    pub schema_version: u32,
219    /// Public result version.
220    pub result_version: u32,
221    /// Completed operation.
222    pub action: ContractAction,
223    /// Selected contracts.
224    pub contracts: Vec<ContractView>,
225    /// Direct links selected by `explain-link`.
226    pub links: Vec<ContractLink>,
227    /// Supplied source-free compatibility results.
228    pub compatibility: Vec<ContractCompatibilitySummary>,
229    /// Structural differences selected by `diff`.
230    pub differences: Vec<ContractDifference>,
231    /// Validation and coverage issues.
232    pub issues: Vec<ContractIssue>,
233    /// `Some(true)` only when validation completed without unknowns or errors.
234    pub valid: Option<bool>,
235    /// Whether all required inputs were observed.
236    pub complete: bool,
237    /// Whether the requested result exceeded its bound.
238    pub truncated: bool,
239}
240
241/// Supported deterministic graph rendering.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
243#[serde(rename_all = "snake_case")]
244pub enum ExportFormat {
245    /// Versioned JSON graph payload.
246    Json,
247    /// `GraphML` document with escaped XML text.
248    GraphMl,
249    /// Markdown tables with escaped cell text.
250    Markdown,
251}
252
253/// Explicit bounds for one graph export.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
255pub struct ExportRequest {
256    /// Output representation.
257    pub format: ExportFormat,
258    /// Maximum number of nodes included.
259    pub max_nodes: usize,
260    /// Maximum number of edges included after node selection.
261    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/// Deterministic, bounded graph export.
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
276pub struct ExportReport {
277    /// Public schema version.
278    pub schema_version: u32,
279    /// Public result version.
280    pub result_version: u32,
281    /// Rendered representation.
282    pub format: ExportFormat,
283    /// Complete rendered document.
284    pub content: String,
285    /// Number of exported nodes.
286    pub exported_nodes: usize,
287    /// Number of exported edges.
288    pub exported_edges: usize,
289    /// Number of omitted input nodes.
290    pub omitted_nodes: usize,
291    /// Number of omitted or ineligible input edges.
292    pub omitted_edges: usize,
293    /// Whether any node or edge was omitted.
294    pub truncated: bool,
295    /// Source-free export limitations.
296    pub warnings: Vec<Warning>,
297}
298
299/// Health category consolidated by the doctor report.
300#[derive(
301    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
302)]
303#[serde(rename_all = "snake_case")]
304pub enum DoctorCategory {
305    /// Database schema and migration compatibility.
306    Schema,
307    /// Store and graph integrity.
308    Integrity,
309    /// Repository snapshot freshness.
310    Freshness,
311    /// Optional provider availability and compatibility.
312    Provider,
313    /// Manifest and effective configuration validity.
314    Config,
315}
316
317/// Conservative health state for an input or derived check.
318#[derive(
319    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
320)]
321#[serde(rename_all = "snake_case")]
322pub enum DoctorStatus {
323    /// The check completed and satisfied its invariant.
324    Healthy,
325    /// The check completed with an actionable limitation.
326    Degraded,
327    /// The check completed and found an invalid state.
328    Failed,
329    /// Required input was absent, partial, stale, or unavailable.
330    Unknown,
331}
332
333/// Exact schema state supplied to the pure doctor function.
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
335pub struct SchemaDoctorInput {
336    /// Stable store or component name.
337    pub name: String,
338    /// Schema version expected by this binary.
339    pub expected_version: u32,
340    /// Observed schema version, or `None` when unavailable.
341    pub actual_version: Option<u32>,
342    /// Whether the exact schema metadata is internally consistent.
343    pub metadata_consistent: Option<bool>,
344}
345
346/// Integrity observation supplied to the pure doctor function.
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
348pub struct IntegrityDoctorInput {
349    /// Stable store or component name.
350    pub name: String,
351    /// `Some(true)` for a completed successful check, `None` when not completed.
352    pub passed: Option<bool>,
353    /// Optional bounded source-free detail.
354    pub detail: Option<String>,
355}
356
357/// Freshness observation supplied to the pure doctor function.
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
359pub struct FreshnessDoctorInput {
360    /// Exact repository identifier.
361    pub repo_id: RepoId,
362    /// Existing repository freshness classification.
363    pub state: RepoFreshnessState,
364    /// Optional bounded source-free reason.
365    pub detail: Option<String>,
366}
367
368/// Provider state accepted by the pure doctor function.
369#[derive(
370    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
371)]
372#[serde(rename_all = "snake_case")]
373pub enum ProviderDoctorStatus {
374    /// Provider and required index are available.
375    Available,
376    /// Provider exists but its repository index is missing.
377    IndexMissing,
378    /// Provider index does not match repository state.
379    Stale,
380    /// Provider cannot be reached.
381    Unavailable,
382    /// Provider public contract is incompatible.
383    Incompatible,
384    /// Provider returned invalid public data.
385    InvalidResponse,
386    /// Provider probe did not complete within its bound.
387    TimedOut,
388}
389
390/// Provider observation supplied to the pure doctor function.
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
392pub struct ProviderDoctorInput {
393    /// Stable provider name.
394    pub name: String,
395    /// Provider availability state.
396    pub status: ProviderDoctorStatus,
397    /// Optional bounded source-free detail.
398    pub detail: Option<String>,
399}
400
401/// Configuration observation supplied to the pure doctor function.
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
403pub struct ConfigDoctorInput {
404    /// Stable workspace, repository, or configuration scope.
405    pub name: String,
406    /// `Some(true)` for validated configuration, `None` when validation was unavailable.
407    pub valid: Option<bool>,
408    /// Optional bounded source-free detail.
409    pub detail: Option<String>,
410}
411
412/// Immutable inputs consolidated by [`doctor`].
413#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
414pub struct DoctorRequest {
415    /// Schema and migration observations.
416    pub schema: Vec<SchemaDoctorInput>,
417    /// Store and graph integrity observations.
418    pub integrity: Vec<IntegrityDoctorInput>,
419    /// Repository freshness observations.
420    pub freshness: Vec<FreshnessDoctorInput>,
421    /// Optional provider observations.
422    pub providers: Vec<ProviderDoctorInput>,
423    /// Configuration observations.
424    pub config: Vec<ConfigDoctorInput>,
425}
426
427/// One deterministic doctor check.
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
429pub struct DoctorCheck {
430    /// Check category.
431    pub category: DoctorCategory,
432    /// Stable checked subject.
433    pub name: String,
434    /// Conservative check result.
435    pub status: DoctorStatus,
436    /// Bounded source-free summary.
437    pub summary: String,
438    /// Concrete remediation when the status is not healthy.
439    pub remediation: Option<String>,
440}
441
442/// Consolidated source-free health report.
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
444pub struct DoctorReport {
445    /// Public schema version.
446    pub schema_version: u32,
447    /// Public result version.
448    pub result_version: u32,
449    /// Most conservative aggregate status.
450    pub status: DoctorStatus,
451    /// Stable checks ordered by category and name.
452    pub checks: Vec<DoctorCheck>,
453    /// Whether every required category had conclusive input.
454    pub complete: bool,
455    /// Number of healthy checks.
456    pub healthy_checks: usize,
457    /// Number of degraded checks.
458    pub degraded_checks: usize,
459    /// Number of failed checks.
460    pub failed_checks: usize,
461    /// Number of checks with unknown state.
462    pub unknown_checks: usize,
463}
464
465/// Stable pagination metadata for delivery envelopes.
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
467pub struct Pagination {
468    /// Metadata schema version.
469    pub version: u32,
470    /// Applied zero-based offset.
471    pub offset: usize,
472    /// Applied page-size bound.
473    pub limit: usize,
474    /// Total items before pagination.
475    pub total: usize,
476    /// Number of items in this page.
477    pub returned: usize,
478    /// Whether more items follow this page.
479    pub has_more: bool,
480}
481
482/// Compact source-free result summary.
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
484pub struct Summary {
485    /// Metadata schema version.
486    pub version: u32,
487    /// Stable summary code.
488    pub code: String,
489    /// Human-readable title.
490    pub title: String,
491    /// Bounded explanation.
492    pub detail: String,
493    /// Whether the result is complete.
494    pub complete: bool,
495}
496
497/// Explicit unresolved interpretation.
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
499pub struct Ambiguity {
500    /// Metadata schema version.
501    pub version: u32,
502    /// Stable ambiguity code.
503    pub code: String,
504    /// Bounded explanation.
505    pub message: String,
506    /// Deterministically ordered candidate identifiers.
507    pub candidates: Vec<String>,
508    /// Concrete way to disambiguate.
509    pub remediation: String,
510}
511
512/// Non-fatal delivery warning.
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
514pub struct Warning {
515    /// Metadata schema version.
516    pub version: u32,
517    /// Stable warning code.
518    pub code: String,
519    /// Bounded source-free message.
520    pub message: String,
521}
522
523/// Explicit follow-up offered to a caller.
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
525pub struct NextAction {
526    /// Metadata schema version.
527    pub version: u32,
528    /// Stable action code.
529    pub code: String,
530    /// Bounded reason for the action.
531    pub reason: String,
532    /// Optional command displayed but never executed by this module.
533    pub command: Option<String>,
534}
535
536/// Versioned page suitable for CLI, MCP, or HTTP delivery.
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
538pub struct Page<T> {
539    /// Public schema version.
540    pub schema_version: u32,
541    /// Public result version.
542    pub result_version: u32,
543    /// Items in deterministic page order.
544    pub items: Vec<T>,
545    /// Pagination metadata.
546    pub pagination: Pagination,
547    /// Compact result summary.
548    pub summary: Summary,
549    /// Explicit unresolved interpretations.
550    pub ambiguities: Vec<Ambiguity>,
551    /// Non-fatal warnings.
552    pub warnings: Vec<Warning>,
553    /// Suggested follow-up actions.
554    pub next_actions: Vec<NextAction>,
555}
556
557/// One named generated JSON Schema.
558#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
559pub struct PublicSchema {
560    /// Stable public type name.
561    pub name: String,
562    /// Contract version for this schema.
563    pub version: u32,
564    /// JSON Schema generated by `schemars`.
565    pub schema: serde_json::Value,
566}
567
568/// Deterministic catalog of stable public request and report schemas.
569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
570pub struct PublicSchemaCatalog {
571    /// Catalog schema version.
572    pub schema_version: u32,
573    /// Catalog result version.
574    pub result_version: u32,
575    /// Schemas ordered by stable public type name.
576    pub schemas: Vec<PublicSchema>,
577}
578
579/// Stable semantic class used to map domain failures to process exit codes.
580#[derive(
581    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
582)]
583#[serde(rename_all = "snake_case")]
584pub enum DomainErrorKind {
585    /// Invalid command or request input.
586    InvalidInput,
587    /// Requested entity does not exist.
588    NotFound,
589    /// Multiple candidates prevent deterministic selection.
590    Ambiguous,
591    /// Existing state conflicts with the operation.
592    Conflict,
593    /// Useful output exists but required coverage is incomplete.
594    Partial,
595    /// A required local or remote capability is unavailable.
596    Unavailable,
597    /// A bounded operation timed out.
598    Timeout,
599    /// Caller cancelled the operation.
600    Cancelled,
601    /// An invariant or unexpected internal operation failed.
602    Internal,
603}
604
605/// Stable process exit classification.
606#[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    /// Successful complete operation.
613    Success = 0,
614    /// Invalid usage or request.
615    InvalidInput = 2,
616    /// Requested entity was not found.
617    NotFound = 3,
618    /// Selection was ambiguous.
619    Ambiguous = 4,
620    /// Existing state conflicted with the operation.
621    Conflict = 5,
622    /// Operation produced only partial or unknown coverage.
623    Partial = 6,
624    /// Required capability was unavailable.
625    Unavailable = 7,
626    /// Operation exceeded its explicit time bound.
627    Timeout = 8,
628    /// Unexpected internal failure.
629    Internal = 70,
630    /// Operation was cancelled by the caller.
631    Cancelled = 130,
632}
633
634impl ExitCode {
635    /// Returns the stable numeric process code.
636    #[must_use]
637    pub const fn value(self) -> u8 {
638        self as u8
639    }
640}
641
642/// Validation and rendering failures from the pure application interface.
643#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Error)]
644#[serde(tag = "kind", rename_all = "snake_case")]
645pub enum InterfaceError {
646    /// A request omitted a field required by its action.
647    #[error("contract action `{action:?}` requires `{field}`")]
648    MissingContractField {
649        /// Requested action.
650        action: ContractAction,
651        /// Missing field name.
652        field: String,
653    },
654    /// A requested graph entity was not observed.
655    #[error("graph entity `{0}` was not found")]
656    NotFound(String),
657    /// An identifier occurred more than once.
658    #[error("duplicate {entity} identifier `{id}`")]
659    DuplicateIdentity {
660        /// Entity family.
661        entity: String,
662        /// Duplicated identifier.
663        id: String,
664    },
665    /// A request bound is zero or exceeds its hard maximum.
666    #[error("requested {name} bound {value} is outside 1..={maximum}")]
667    InvalidBound {
668        /// Bound name.
669        name: String,
670        /// Supplied value.
671        value: usize,
672        /// Inclusive hard maximum.
673        maximum: usize,
674    },
675    /// A public schema or deterministic payload could not be serialized.
676    #[error("failed to serialize `{0}`")]
677    Serialization(String),
678}
679
680/// Performs a bounded contract operation over immutable graph inputs.
681///
682/// Evidence notes are never copied into the returned report. Missing links and missing
683/// compatibility inputs remain explicit unknown coverage rather than evidence of safety.
684///
685/// # Errors
686///
687/// Returns [`InterfaceError`] for malformed requests, duplicate identities, missing requested
688/// entities, or unsupported bounds.
689pub 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
918/// Renders a deterministic bounded graph with source-free evidence metadata.
919///
920/// Nodes are ordered by stable key and identifier. Edges are ordered by identifier and are
921/// exported only when both endpoints are in the selected node set.
922///
923/// # Errors
924///
925/// Returns [`InterfaceError`] for invalid bounds, duplicate identities, or JSON serialization
926/// failure.
927pub 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/// Consolidates schema, integrity, freshness, provider, and configuration observations.
1018///
1019/// Failed checks dominate. Otherwise any unknown check forces an unknown aggregate; degraded
1020/// status is returned only when every check is conclusive.
1021#[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
1061/// Builds a deterministic bounded page from an already ordered item slice.
1062///
1063/// # Errors
1064///
1065/// Returns [`InterfaceError::InvalidBound`] when `limit` is zero or exceeds the export node hard
1066/// bound.
1067pub 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
1101/// Generates the deterministic catalog of stable public request and report schemas.
1102///
1103/// # Errors
1104///
1105/// Returns [`InterfaceError::Serialization`] if a generated schema cannot be represented as JSON.
1106pub 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/// Maps a domain error class to its stable process exit code.
1134#[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/// Classifies an application-interface error using the stable domain mapping.
1150#[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("&amp;"),
1631            '<' => escaped.push_str("&lt;"),
1632            '>' => escaped.push_str("&gt;"),
1633            '"' => escaped.push_str("&quot;"),
1634            '\'' => escaped.push_str("&apos;"),
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("&lt;script&gt;&amp;&quot;&apos;")
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}