1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CorpusScope {
12 Production,
13 Test,
14 Fixture,
15 Example,
16 Generated,
17 Vendor,
18 Docs,
19}
20
21impl CorpusScope {
22 pub const ALL: [Self; 7] = [
23 Self::Production,
24 Self::Test,
25 Self::Fixture,
26 Self::Example,
27 Self::Generated,
28 Self::Vendor,
29 Self::Docs,
30 ];
31
32 pub fn as_str(self) -> &'static str {
33 match self {
34 Self::Production => "production",
35 Self::Test => "test",
36 Self::Fixture => "fixture",
37 Self::Example => "example",
38 Self::Generated => "generated",
39 Self::Vendor => "vendor",
40 Self::Docs => "docs",
41 }
42 }
43
44 pub fn from_str_opt(value: &str) -> Option<Self> {
45 Some(match value {
46 "production" | "prod" => Self::Production,
47 "test" | "tests" => Self::Test,
48 "fixture" | "fixtures" => Self::Fixture,
49 "example" | "examples" => Self::Example,
50 "generated" => Self::Generated,
51 "vendor" | "vendored" => Self::Vendor,
52 "docs" | "documentation" => Self::Docs,
53 _ => return None,
54 })
55 }
56
57 pub fn classify_path(file: &str) -> Self {
71 if file.starts_with("dep:") {
72 return Self::Vendor;
73 }
74 let lower = file.replace('\\', "/").to_ascii_lowercase();
75 let components = lower.split('/').collect::<Vec<_>>();
76 let basename = components.last().copied().unwrap_or(&lower);
77
78 if components.iter().any(|component| {
79 matches!(
80 *component,
81 "vendor" | "vendored" | "third_party" | "third-party" | "node_modules"
82 )
83 }) {
84 return Self::Vendor;
85 }
86 if components.iter().any(|component| {
87 matches!(
88 *component,
89 "generated" | "autogen" | "auto-generated" | "generated-src"
90 )
91 }) || basename.contains(".generated.")
92 || basename.contains("_generated.")
93 || basename.ends_with(".g.rs")
94 || basename.ends_with(".pb.go")
95 || basename.ends_with(".designer.cs")
96 {
97 return Self::Generated;
98 }
99 if components.iter().any(|component| {
100 matches!(
101 *component,
102 "fixture"
103 | "fixtures"
104 | "golden"
105 | "testdata"
106 | "test-data"
107 | "expected"
108 | "worked"
109 | "snapshot"
110 | "snapshots"
111 | "__snapshots__"
112 )
113 }) {
114 return Self::Fixture;
115 }
116 if components.iter().any(|component| {
117 matches!(
118 *component,
119 "example" | "examples" | "sample" | "samples" | "demo" | "demos"
120 )
121 }) {
122 return Self::Example;
123 }
124 if components
125 .iter()
126 .any(|component| matches!(*component, "test" | "tests" | "spec" | "specs"))
127 || components.first().is_some_and(|component| {
128 matches!(
129 *component,
130 "harness"
131 | "bench"
132 | "benches"
133 | "benchmark"
134 | "benchmarks"
135 | "eval"
136 | "evals"
137 | "e2e"
138 )
139 })
140 || basename.starts_with("test_")
141 || basename == "tests.rs"
142 || basename.ends_with("_tests.rs")
143 || basename.contains("_test.")
144 || basename.contains(".test.")
145 || basename.contains("_spec.")
146 || basename.contains(".spec.")
147 {
148 return Self::Test;
149 }
150 if components
151 .first()
152 .is_some_and(|component| matches!(*component, "docs" | "doc" | "documentation"))
153 || matches!(
154 basename,
155 "readme"
156 | "readme.md"
157 | "readme.mdx"
158 | "changelog.md"
159 | "contributing.md"
160 | "architecture.md"
161 )
162 || [".md", ".mdx", ".rst", ".adoc", ".asciidoc"]
163 .iter()
164 .any(|extension| basename.ends_with(extension))
165 {
166 return Self::Docs;
167 }
168 Self::Production
169 }
170}
171
172impl std::fmt::Display for CorpusScope {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 f.write_str(self.as_str())
175 }
176}
177
178impl std::str::FromStr for CorpusScope {
179 type Err = String;
180
181 fn from_str(value: &str) -> Result<Self, Self::Err> {
182 Self::from_str_opt(value).ok_or_else(|| {
183 format!(
184 "unknown scope `{value}` (expected production, test, fixture, example, generated, vendor, or docs)"
185 )
186 })
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
195pub struct NodeId(String);
196
197impl NodeId {
198 pub fn new(id: impl Into<String>) -> Self {
199 Self(id.into())
200 }
201
202 pub fn as_str(&self) -> &str {
203 &self.0
204 }
205
206 pub fn qualified(&self) -> &str {
209 match self.0.split_once('#') {
210 Some((_, rest)) => rest
211 .rsplit_once('@')
212 .map_or(rest, |(qualified, _)| qualified),
213 None => &self.0,
214 }
215 }
216}
217
218impl std::fmt::Display for NodeId {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.write_str(&self.0)
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
232pub struct SymbolKey(String);
233
234impl SymbolKey {
235 pub const PREFIX: &'static str = "symbol:";
236
237 pub fn new(kind: SymbolKind, file: &str, qualified: &str) -> Self {
238 Self(format!(
239 "{}{}:{}:{file}#{qualified}",
240 Self::PREFIX,
241 kind.as_str(),
242 file.len()
243 ))
244 }
245
246 pub fn parse(encoded: impl Into<String>) -> Option<Self> {
247 let key = Self(encoded.into());
248 key.parts()?;
249 Some(key)
250 }
251
252 pub fn as_str(&self) -> &str {
253 &self.0
254 }
255
256 pub fn parts(&self) -> Option<(SymbolKind, &str, &str)> {
257 let rest = self.0.strip_prefix(Self::PREFIX)?;
258 let (kind, rest) = rest.split_once(':')?;
259 let (file_len, rest) = rest.split_once(':')?;
260 let file_len: usize = file_len.parse().ok()?;
261 let file = rest.get(..file_len)?;
262 let qualified = rest.get(file_len..)?.strip_prefix('#')?;
263 if file.is_empty() || qualified.is_empty() {
264 return None;
265 }
266 Some((SymbolKind::from_str_opt(kind)?, file, qualified))
267 }
268}
269
270impl std::fmt::Display for SymbolKey {
271 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272 f.write_str(&self.0)
273 }
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
279pub struct Span {
280 pub start: u64,
281 pub end: u64,
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
285pub enum SymbolKind {
286 Function,
287 Method,
288 Struct,
289 Class,
290 Enum,
291 Variant,
292 Trait,
293 Interface,
294 TypeAlias,
295 Constant,
296 Static,
297 Variable,
298 Field,
299 Module,
300 Macro,
301 File,
302 Section,
305 Table,
307 View,
309 Column,
311 Index,
313}
314
315impl SymbolKind {
316 pub fn as_str(self) -> &'static str {
317 match self {
318 Self::Function => "function",
319 Self::Method => "method",
320 Self::Struct => "struct",
321 Self::Class => "class",
322 Self::Enum => "enum",
323 Self::Variant => "variant",
324 Self::Trait => "trait",
325 Self::Interface => "interface",
326 Self::TypeAlias => "typealias",
327 Self::Constant => "constant",
328 Self::Static => "static",
329 Self::Variable => "variable",
330 Self::Field => "field",
331 Self::Module => "module",
332 Self::Macro => "macro",
333 Self::File => "file",
334 Self::Section => "section",
335 Self::Table => "table",
336 Self::View => "view",
337 Self::Column => "column",
338 Self::Index => "index",
339 }
340 }
341
342 pub fn from_str_opt(s: &str) -> Option<Self> {
345 Some(match s {
346 "function" => Self::Function,
347 "method" => Self::Method,
348 "struct" => Self::Struct,
349 "class" => Self::Class,
350 "enum" => Self::Enum,
351 "variant" => Self::Variant,
352 "trait" => Self::Trait,
353 "interface" => Self::Interface,
354 "typealias" => Self::TypeAlias,
355 "constant" => Self::Constant,
356 "static" => Self::Static,
357 "variable" => Self::Variable,
358 "field" => Self::Field,
359 "module" => Self::Module,
360 "macro" => Self::Macro,
361 "file" => Self::File,
362 "section" => Self::Section,
363 "table" => Self::Table,
364 "view" => Self::View,
365 "column" => Self::Column,
366 "index" => Self::Index,
367 _ => return None,
368 })
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375pub struct Node {
376 pub id: NodeId,
377 pub kind: SymbolKind,
378 pub name: String,
379 pub file: String,
381 pub span: Span,
382 pub signature: String,
384 pub doc: Option<String>,
385}
386
387impl Node {
388 pub fn symbol_key(&self) -> SymbolKey {
392 SymbolKey::new(self.kind, &self.file, self.id.qualified())
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::{CorpusScope, Node, NodeId, Span, SymbolKey, SymbolKind};
399
400 fn node(id: &str, signature: &str) -> Node {
401 Node {
402 id: NodeId::new(id),
403 kind: SymbolKind::Function,
404 name: "run".to_string(),
405 file: "src/lib.rs".to_string(),
406 span: Span { start: 10, end: 20 },
407 signature: signature.to_string(),
408 doc: None,
409 }
410 }
411
412 #[test]
413 fn symbol_key_survives_offset_changes() {
414 let before = node("src/lib.rs#Runner::run@10", "fn run()");
415 let after = node("src/lib.rs#Runner::run@200", "fn run()");
416 assert_ne!(before.id, after.id);
417 assert_eq!(before.symbol_key(), after.symbol_key());
418 assert_eq!(
419 before.symbol_key().parts(),
420 Some((SymbolKind::Function, "src/lib.rs", "Runner::run"))
421 );
422 assert_eq!(
423 SymbolKey::parse(before.symbol_key().to_string()),
424 Some(before.symbol_key())
425 );
426 }
427
428 #[test]
429 fn overloads_share_a_key_without_claiming_uniqueness() {
430 let one = node("src/lib.rs#run@10", "fn run(u8)");
431 let two = node("src/lib.rs#run@30", "fn run(u16)");
432 assert_eq!(one.symbol_key(), two.symbol_key());
433 }
434
435 #[test]
436 fn sql_symbol_kinds_round_trip_through_capture_names() {
437 for kind in [
438 SymbolKind::Table,
439 SymbolKind::View,
440 SymbolKind::Column,
441 SymbolKind::Index,
442 ] {
443 assert_eq!(SymbolKind::from_str_opt(kind.as_str()), Some(kind));
444 }
445 }
446
447 #[test]
448 fn corpus_scope_uses_conservative_path_roles() {
449 let cases = [
450 ("src/lib.rs", CorpusScope::Production),
451 ("tests/integration.rs", CorpusScope::Test),
452 ("harness/golden/example/main.rs", CorpusScope::Fixture),
453 ("examples/client.rs", CorpusScope::Example),
454 ("src/generated/schema.pb.go", CorpusScope::Generated),
455 ("third_party/parser.c", CorpusScope::Vendor),
456 ("docs/architecture.md", CorpusScope::Docs),
457 ("src/contest.rs", CorpusScope::Production),
458 ("harness/eval/runner/scoring.rs", CorpusScope::Test),
459 (
460 "harness/eval/fixtures/agent-flow/main.rs",
461 CorpusScope::Fixture,
462 ),
463 (
464 "harness/golden/fixtures/go-basic/main.go",
465 CorpusScope::Fixture,
466 ),
467 ("benches/ask.rs", CorpusScope::Test),
468 ("tools/skillgen/expected/card.md", CorpusScope::Fixture),
469 ("worked/mixed-corpus/raw/cluster.py", CorpusScope::Fixture),
470 ("samples/demo.py", CorpusScope::Example),
471 ("e2e/smoke.ts", CorpusScope::Test),
472 ("crates/sinter-cli/tests/cli.rs", CorpusScope::Test),
473 ("crates/eval/src/lib.rs", CorpusScope::Production),
474 ("src/eval/mod.rs", CorpusScope::Production),
475 ("src/tests.rs", CorpusScope::Test),
476 ("crates/foo/src/bar/tests.rs", CorpusScope::Test),
477 ("src/bar_tests.rs", CorpusScope::Test),
478 ("src/test_bar.rs", CorpusScope::Test),
479 ("crates/foo/src/bar/tests/cases.rs", CorpusScope::Test),
480 ("src/contests.rs", CorpusScope::Production),
481 ];
482 for (path, expected) in cases {
483 assert_eq!(CorpusScope::classify_path(path), expected, "{path}");
484 }
485 }
486}