1use serde::{Deserialize, Serialize};
28
29use super::anchor::validate_anchor_path;
30use super::sealed::SealedBlobV1;
31use super::{require_schema, validate_text};
32use crate::ValidationError;
33
34pub const CODE_GRAPH_SCHEMA: &str = "leanctx.hive.code-graph/v1";
35pub const CODE_GRAPH_PUT_SCHEMA: &str = "leanctx.hive.code-graph-put/v1";
36pub const CODE_GRAPH_ACK_SCHEMA: &str = "leanctx.hive.code-graph-ack/v1";
37pub const MAX_CODE_GRAPH_NODES: usize = super::graph::MAX_GRAPH_NODES;
38pub const MAX_CODE_GRAPH_EDGES: usize = super::graph::MAX_GRAPH_EDGES;
39pub const MAX_CODE_NODE_ID_CHARS: usize = 700;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum CodeGraphMode {
46 Anchors,
47 Full,
48 #[serde(other)]
49 Unknown,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum CodeNodeKind {
55 File,
56 Symbol,
57 #[serde(other)]
58 Unknown,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum CodeEdgeKind {
64 Contains,
66 Imports,
68 Calls,
70 #[serde(other)]
71 Unknown,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct CodeNodeV1 {
78 pub id: String,
79 pub kind: CodeNodeKind,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub lang: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub symbol_kind: Option<String>,
86}
87
88impl CodeNodeV1 {
89 fn validate(&self) -> Result<(), ValidationError> {
90 if self.id.chars().count() > MAX_CODE_NODE_ID_CHARS {
91 return Err(ValidationError::new(format!(
92 "a code node id has at most {MAX_CODE_NODE_ID_CHARS} characters"
93 )));
94 }
95 let rest = self
96 .id
97 .strip_prefix("code:")
98 .ok_or_else(|| ValidationError::new("code node ids start with code:"))?;
99 let (path, symbol) = match rest.split_once('#') {
100 Some((path, symbol)) => (path, Some(symbol)),
101 None => (rest, None),
102 };
103 validate_anchor_path(path)?;
104 match (self.kind, symbol) {
105 (CodeNodeKind::File, None) => {}
106 (CodeNodeKind::Symbol, Some(symbol)) => {
107 validate_text(
108 symbol,
109 super::anchor::MAX_ANCHOR_SYMBOL_CHARS,
110 "symbol",
111 false,
112 )?;
113 }
114 _ => {
115 return Err(ValidationError::new(
116 "a file node has no #symbol and a symbol node has one",
117 ));
118 }
119 }
120 for (hint, field) in [(&self.lang, "lang"), (&self.symbol_kind, "symbol_kind")] {
121 if let Some(hint) = hint
122 && (hint.is_empty()
123 || hint.len() > 32
124 || !hint
125 .bytes()
126 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'))
127 {
128 return Err(ValidationError::new(format!(
129 "{field} is 1-32 characters of [a-z0-9_]"
130 )));
131 }
132 }
133 Ok(())
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct CodeEdgeV1 {
140 pub from: String,
141 pub to: String,
142 #[serde(rename = "type")]
143 pub kind: CodeEdgeKind,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct CodeGraphV1 {
150 pub schema: String,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub revision_hint: Option<String>,
154 pub nodes: Vec<CodeNodeV1>,
156 pub edges: Vec<CodeEdgeV1>,
158}
159
160impl CodeGraphV1 {
161 pub fn validate(&self) -> Result<(), ValidationError> {
162 require_schema(&self.schema, CODE_GRAPH_SCHEMA)?;
163 if let Some(hint) = &self.revision_hint
164 && !(matches!(hint.len(), 40 | 64)
165 && hint
166 .bytes()
167 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
168 {
169 return Err(ValidationError::new("revision_hint is a git commit id"));
170 }
171 if self.nodes.len() > MAX_CODE_GRAPH_NODES || self.edges.len() > MAX_CODE_GRAPH_EDGES {
172 return Err(ValidationError::new(format!(
173 "a code graph has at most {MAX_CODE_GRAPH_NODES} nodes and {MAX_CODE_GRAPH_EDGES} edges"
174 )));
175 }
176 for (i, node) in self.nodes.iter().enumerate() {
177 node.validate()?;
178 if i > 0 && self.nodes[i - 1].id >= node.id {
179 return Err(ValidationError::new(
180 "nodes must be sorted by id without repeats",
181 ));
182 }
183 }
184 let known = |id: &str| {
185 self.nodes
186 .binary_search_by(|node| node.id.as_str().cmp(id))
187 .is_ok()
188 };
189 for (i, edge) in self.edges.iter().enumerate() {
190 if edge.kind == CodeEdgeKind::Unknown {
191 return Err(ValidationError::new("unknown edge type"));
192 }
193 if !known(&edge.from) || !known(&edge.to) {
194 return Err(ValidationError::new("an edge names a missing node"));
195 }
196 if i > 0 {
197 let prev = &self.edges[i - 1];
198 if (&prev.from, &prev.to, prev.kind) >= (&edge.from, &edge.to, edge.kind) {
199 return Err(ValidationError::new("edges must be sorted without repeats"));
200 }
201 }
202 }
203 Ok(())
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct CodeGraphPutV1 {
211 pub schema: String,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub graph: Option<CodeGraphV1>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub sealed: Option<SealedBlobV1>,
216}
217
218impl CodeGraphPutV1 {
219 pub fn validate(&self) -> Result<(), ValidationError> {
220 require_schema(&self.schema, CODE_GRAPH_PUT_SCHEMA)?;
221 match (&self.graph, &self.sealed) {
222 (Some(graph), None) => graph.validate(),
223 (None, Some(sealed)) => sealed.validate(),
224 _ => Err(ValidationError::new(
225 "a code graph upload carries exactly one of graph and sealed",
226 )),
227 }
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct CodeGraphAckV1 {
233 pub schema: String,
234 pub nodes: u64,
236 pub edges: u64,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub revision_hint: Option<String>,
239}