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 | "snapshot"
108 | "snapshots"
109 | "__snapshots__"
110 )
111 }) {
112 return Self::Fixture;
113 }
114 if components.iter().any(|component| {
115 matches!(
116 *component,
117 "example" | "examples" | "sample" | "samples" | "demo" | "demos"
118 )
119 }) {
120 return Self::Example;
121 }
122 if components
123 .iter()
124 .any(|component| matches!(*component, "test" | "tests" | "spec" | "specs"))
125 || components.first().is_some_and(|component| {
126 matches!(
127 *component,
128 "harness"
129 | "bench"
130 | "benches"
131 | "benchmark"
132 | "benchmarks"
133 | "eval"
134 | "evals"
135 | "e2e"
136 )
137 })
138 || basename.starts_with("test_")
139 || basename == "tests.rs"
140 || basename.ends_with("_tests.rs")
141 || basename.contains("_test.")
142 || basename.contains(".test.")
143 || basename.contains("_spec.")
144 || basename.contains(".spec.")
145 {
146 return Self::Test;
147 }
148 if components
149 .first()
150 .is_some_and(|component| matches!(*component, "docs" | "doc" | "documentation"))
151 || matches!(
152 basename,
153 "readme"
154 | "readme.md"
155 | "readme.mdx"
156 | "changelog.md"
157 | "contributing.md"
158 | "architecture.md"
159 )
160 || [".md", ".mdx", ".rst", ".adoc", ".asciidoc"]
161 .iter()
162 .any(|extension| basename.ends_with(extension))
163 {
164 return Self::Docs;
165 }
166 Self::Production
167 }
168}
169
170impl std::fmt::Display for CorpusScope {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 f.write_str(self.as_str())
173 }
174}
175
176impl std::str::FromStr for CorpusScope {
177 type Err = String;
178
179 fn from_str(value: &str) -> Result<Self, Self::Err> {
180 Self::from_str_opt(value).ok_or_else(|| {
181 format!(
182 "unknown scope `{value}` (expected production, test, fixture, example, generated, vendor, or docs)"
183 )
184 })
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
193pub struct NodeId(String);
194
195impl NodeId {
196 pub fn new(id: impl Into<String>) -> Self {
197 Self(id.into())
198 }
199
200 pub fn as_str(&self) -> &str {
201 &self.0
202 }
203
204 pub fn qualified(&self) -> &str {
207 match self.0.split_once('#') {
208 Some((_, rest)) => rest
209 .rsplit_once('@')
210 .map_or(rest, |(qualified, _)| qualified),
211 None => &self.0,
212 }
213 }
214}
215
216impl std::fmt::Display for NodeId {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 f.write_str(&self.0)
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
230pub struct SymbolKey(String);
231
232impl SymbolKey {
233 pub const PREFIX: &'static str = "symbol:";
234
235 pub fn new(kind: SymbolKind, file: &str, qualified: &str) -> Self {
236 Self(format!(
237 "{}{}:{}:{file}#{qualified}",
238 Self::PREFIX,
239 kind.as_str(),
240 file.len()
241 ))
242 }
243
244 pub fn parse(encoded: impl Into<String>) -> Option<Self> {
245 let key = Self(encoded.into());
246 key.parts()?;
247 Some(key)
248 }
249
250 pub fn as_str(&self) -> &str {
251 &self.0
252 }
253
254 pub fn parts(&self) -> Option<(SymbolKind, &str, &str)> {
255 let rest = self.0.strip_prefix(Self::PREFIX)?;
256 let (kind, rest) = rest.split_once(':')?;
257 let (file_len, rest) = rest.split_once(':')?;
258 let file_len: usize = file_len.parse().ok()?;
259 let file = rest.get(..file_len)?;
260 let qualified = rest.get(file_len..)?.strip_prefix('#')?;
261 if file.is_empty() || qualified.is_empty() {
262 return None;
263 }
264 Some((SymbolKind::from_str_opt(kind)?, file, qualified))
265 }
266}
267
268impl std::fmt::Display for SymbolKey {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 f.write_str(&self.0)
271 }
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
277pub struct Span {
278 pub start: u64,
279 pub end: u64,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
283pub enum SymbolKind {
284 Function,
285 Method,
286 Struct,
287 Class,
288 Enum,
289 Variant,
290 Trait,
291 Interface,
292 TypeAlias,
293 Constant,
294 Static,
295 Variable,
296 Field,
297 Module,
298 Macro,
299 File,
300 Section,
303}
304
305impl SymbolKind {
306 pub fn as_str(self) -> &'static str {
307 match self {
308 Self::Function => "function",
309 Self::Method => "method",
310 Self::Struct => "struct",
311 Self::Class => "class",
312 Self::Enum => "enum",
313 Self::Variant => "variant",
314 Self::Trait => "trait",
315 Self::Interface => "interface",
316 Self::TypeAlias => "typealias",
317 Self::Constant => "constant",
318 Self::Static => "static",
319 Self::Variable => "variable",
320 Self::Field => "field",
321 Self::Module => "module",
322 Self::Macro => "macro",
323 Self::File => "file",
324 Self::Section => "section",
325 }
326 }
327
328 pub fn from_str_opt(s: &str) -> Option<Self> {
331 Some(match s {
332 "function" => Self::Function,
333 "method" => Self::Method,
334 "struct" => Self::Struct,
335 "class" => Self::Class,
336 "enum" => Self::Enum,
337 "variant" => Self::Variant,
338 "trait" => Self::Trait,
339 "interface" => Self::Interface,
340 "typealias" => Self::TypeAlias,
341 "constant" => Self::Constant,
342 "static" => Self::Static,
343 "variable" => Self::Variable,
344 "field" => Self::Field,
345 "module" => Self::Module,
346 "macro" => Self::Macro,
347 "file" => Self::File,
348 "section" => Self::Section,
349 _ => return None,
350 })
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357pub struct Node {
358 pub id: NodeId,
359 pub kind: SymbolKind,
360 pub name: String,
361 pub file: String,
363 pub span: Span,
364 pub signature: String,
366 pub doc: Option<String>,
367}
368
369impl Node {
370 pub fn symbol_key(&self) -> SymbolKey {
374 SymbolKey::new(self.kind, &self.file, self.id.qualified())
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::{CorpusScope, Node, NodeId, Span, SymbolKey, SymbolKind};
381
382 fn node(id: &str, signature: &str) -> Node {
383 Node {
384 id: NodeId::new(id),
385 kind: SymbolKind::Function,
386 name: "run".to_string(),
387 file: "src/lib.rs".to_string(),
388 span: Span { start: 10, end: 20 },
389 signature: signature.to_string(),
390 doc: None,
391 }
392 }
393
394 #[test]
395 fn symbol_key_survives_offset_changes() {
396 let before = node("src/lib.rs#Runner::run@10", "fn run()");
397 let after = node("src/lib.rs#Runner::run@200", "fn run()");
398 assert_ne!(before.id, after.id);
399 assert_eq!(before.symbol_key(), after.symbol_key());
400 assert_eq!(
401 before.symbol_key().parts(),
402 Some((SymbolKind::Function, "src/lib.rs", "Runner::run"))
403 );
404 assert_eq!(
405 SymbolKey::parse(before.symbol_key().to_string()),
406 Some(before.symbol_key())
407 );
408 }
409
410 #[test]
411 fn overloads_share_a_key_without_claiming_uniqueness() {
412 let one = node("src/lib.rs#run@10", "fn run(u8)");
413 let two = node("src/lib.rs#run@30", "fn run(u16)");
414 assert_eq!(one.symbol_key(), two.symbol_key());
415 }
416
417 #[test]
418 fn corpus_scope_uses_conservative_path_roles() {
419 let cases = [
420 ("src/lib.rs", CorpusScope::Production),
421 ("tests/integration.rs", CorpusScope::Test),
422 ("harness/golden/example/main.rs", CorpusScope::Fixture),
423 ("examples/client.rs", CorpusScope::Example),
424 ("src/generated/schema.pb.go", CorpusScope::Generated),
425 ("third_party/parser.c", CorpusScope::Vendor),
426 ("docs/architecture.md", CorpusScope::Docs),
427 ("src/contest.rs", CorpusScope::Production),
428 ("harness/eval/runner/scoring.rs", CorpusScope::Test),
429 (
430 "harness/eval/fixtures/agent-flow/main.rs",
431 CorpusScope::Fixture,
432 ),
433 (
434 "harness/golden/fixtures/go-basic/main.go",
435 CorpusScope::Fixture,
436 ),
437 ("benches/ask.rs", CorpusScope::Test),
438 ("e2e/smoke.ts", CorpusScope::Test),
439 ("crates/sinter-cli/tests/cli.rs", CorpusScope::Test),
440 ("crates/eval/src/lib.rs", CorpusScope::Production),
441 ("src/eval/mod.rs", CorpusScope::Production),
442 ("src/tests.rs", CorpusScope::Test),
443 ("crates/foo/src/bar/tests.rs", CorpusScope::Test),
444 ("src/bar_tests.rs", CorpusScope::Test),
445 ("src/test_bar.rs", CorpusScope::Test),
446 ("crates/foo/src/bar/tests/cases.rs", CorpusScope::Test),
447 ("src/contests.rs", CorpusScope::Production),
448 ];
449 for (path, expected) in cases {
450 assert_eq!(CorpusScope::classify_path(path), expected, "{path}");
451 }
452 }
453}