Skip to main content

sinter_core/
node.rs

1use serde::{Deserialize, Serialize};
2
3/// Repository role of a source file and every graph node declared in it.
4///
5/// Scope is persisted per file by the store rather than repeated in every
6/// node blob. The path classifier is deliberately conservative: repositories
7/// can override an exceptional path, while an uncertain path remains
8/// production instead of disappearing from the default agent corpus.
9#[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    /// Conservative path-only classification used when a repository has no
58    /// explicit override. Rules operate on complete path components and
59    /// well-known generated suffixes to avoid hiding ordinary source whose
60    /// name merely contains words such as `test` or `example`.
61    ///
62    /// Test-infrastructure directories (`harness`, `bench`, `benches`,
63    /// `benchmark`, `benchmarks`, `eval`, `evals`, `e2e`) count only as the
64    /// top-level component: a crate or module literally named `eval` under
65    /// `crates/` or `src/` stays production. Nested `fixtures`, `golden`,
66    /// `testdata`, `examples`, and `tests` components still match anywhere.
67    ///
68    /// Rust convention: `tests.rs`, `*_tests.rs`, and `test_*.rs` basenames
69    /// are test files wherever they sit under `src/`.
70    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/// Identifier of a graph node.
189///
190/// Comparison is byte-exact and case-sensitive: `Config` and `config` are
191/// distinct ids. A collision on insert is an error, never a merge.
192#[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    /// The qualified declaration path encoded in this snapshot-local id.
205    /// File-node ids contain no `#` and therefore qualify as their path.
206    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/// A declaration handle that is independent of byte offsets.
223///
224/// The encoded form is `symbol:<kind>:<file-byte-length>:<file>#<qualified>`.
225/// The length prefix keeps parsing unambiguous even when a path contains `#`.
226/// A key is deliberately not a unique id: overloads or duplicate declarations
227/// with the same kind and qualified path share it, and callers must handle the
228/// resulting candidate set rather than guessing a binding.
229#[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/// Byte range of a symbol in its source file. `end` is exclusive; a valid
275/// span has `end > start`.
276#[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    /// A prose document section (markdown heading); appended for postcard
301    /// wire compatibility — never reorder.
302    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    /// Inverse of [`SymbolKind::as_str`]; the mapping extraction query
329    /// captures (`@def.<kind>`) resolve through.
330    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/// A symbol with enough content that a query result saves the consumer a
355/// file read: signature, doc comment, and exact byte span.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357pub struct Node {
358    pub id: NodeId,
359    pub kind: SymbolKind,
360    pub name: String,
361    /// Repo-relative source file path.
362    pub file: String,
363    pub span: Span,
364    /// Declaration text; may be empty for kinds with no meaningful signature.
365    pub signature: String,
366    pub doc: Option<String>,
367}
368
369impl Node {
370    /// Stable semantic handle for this declaration. It survives unrelated
371    /// text inserted before the declaration; unlike [`NodeId`], it does not
372    /// claim uniqueness among overloads or duplicate declarations.
373    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}