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    /// A relational database table. Appended for postcard wire compatibility.
304    Table,
305    /// A named relational view, including materialized views.
306    View,
307    /// A column declared inside a table.
308    Column,
309    /// A named database index.
310    Index,
311}
312
313impl SymbolKind {
314    pub fn as_str(self) -> &'static str {
315        match self {
316            Self::Function => "function",
317            Self::Method => "method",
318            Self::Struct => "struct",
319            Self::Class => "class",
320            Self::Enum => "enum",
321            Self::Variant => "variant",
322            Self::Trait => "trait",
323            Self::Interface => "interface",
324            Self::TypeAlias => "typealias",
325            Self::Constant => "constant",
326            Self::Static => "static",
327            Self::Variable => "variable",
328            Self::Field => "field",
329            Self::Module => "module",
330            Self::Macro => "macro",
331            Self::File => "file",
332            Self::Section => "section",
333            Self::Table => "table",
334            Self::View => "view",
335            Self::Column => "column",
336            Self::Index => "index",
337        }
338    }
339
340    /// Inverse of [`SymbolKind::as_str`]; the mapping extraction query
341    /// captures (`@def.<kind>`) resolve through.
342    pub fn from_str_opt(s: &str) -> Option<Self> {
343        Some(match s {
344            "function" => Self::Function,
345            "method" => Self::Method,
346            "struct" => Self::Struct,
347            "class" => Self::Class,
348            "enum" => Self::Enum,
349            "variant" => Self::Variant,
350            "trait" => Self::Trait,
351            "interface" => Self::Interface,
352            "typealias" => Self::TypeAlias,
353            "constant" => Self::Constant,
354            "static" => Self::Static,
355            "variable" => Self::Variable,
356            "field" => Self::Field,
357            "module" => Self::Module,
358            "macro" => Self::Macro,
359            "file" => Self::File,
360            "section" => Self::Section,
361            "table" => Self::Table,
362            "view" => Self::View,
363            "column" => Self::Column,
364            "index" => Self::Index,
365            _ => return None,
366        })
367    }
368}
369
370/// A symbol with enough content that a query result saves the consumer a
371/// file read: signature, doc comment, and exact byte span.
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct Node {
374    pub id: NodeId,
375    pub kind: SymbolKind,
376    pub name: String,
377    /// Repo-relative source file path.
378    pub file: String,
379    pub span: Span,
380    /// Declaration text; may be empty for kinds with no meaningful signature.
381    pub signature: String,
382    pub doc: Option<String>,
383}
384
385impl Node {
386    /// Stable semantic handle for this declaration. It survives unrelated
387    /// text inserted before the declaration; unlike [`NodeId`], it does not
388    /// claim uniqueness among overloads or duplicate declarations.
389    pub fn symbol_key(&self) -> SymbolKey {
390        SymbolKey::new(self.kind, &self.file, self.id.qualified())
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::{CorpusScope, Node, NodeId, Span, SymbolKey, SymbolKind};
397
398    fn node(id: &str, signature: &str) -> Node {
399        Node {
400            id: NodeId::new(id),
401            kind: SymbolKind::Function,
402            name: "run".to_string(),
403            file: "src/lib.rs".to_string(),
404            span: Span { start: 10, end: 20 },
405            signature: signature.to_string(),
406            doc: None,
407        }
408    }
409
410    #[test]
411    fn symbol_key_survives_offset_changes() {
412        let before = node("src/lib.rs#Runner::run@10", "fn run()");
413        let after = node("src/lib.rs#Runner::run@200", "fn run()");
414        assert_ne!(before.id, after.id);
415        assert_eq!(before.symbol_key(), after.symbol_key());
416        assert_eq!(
417            before.symbol_key().parts(),
418            Some((SymbolKind::Function, "src/lib.rs", "Runner::run"))
419        );
420        assert_eq!(
421            SymbolKey::parse(before.symbol_key().to_string()),
422            Some(before.symbol_key())
423        );
424    }
425
426    #[test]
427    fn overloads_share_a_key_without_claiming_uniqueness() {
428        let one = node("src/lib.rs#run@10", "fn run(u8)");
429        let two = node("src/lib.rs#run@30", "fn run(u16)");
430        assert_eq!(one.symbol_key(), two.symbol_key());
431    }
432
433    #[test]
434    fn sql_symbol_kinds_round_trip_through_capture_names() {
435        for kind in [
436            SymbolKind::Table,
437            SymbolKind::View,
438            SymbolKind::Column,
439            SymbolKind::Index,
440        ] {
441            assert_eq!(SymbolKind::from_str_opt(kind.as_str()), Some(kind));
442        }
443    }
444
445    #[test]
446    fn corpus_scope_uses_conservative_path_roles() {
447        let cases = [
448            ("src/lib.rs", CorpusScope::Production),
449            ("tests/integration.rs", CorpusScope::Test),
450            ("harness/golden/example/main.rs", CorpusScope::Fixture),
451            ("examples/client.rs", CorpusScope::Example),
452            ("src/generated/schema.pb.go", CorpusScope::Generated),
453            ("third_party/parser.c", CorpusScope::Vendor),
454            ("docs/architecture.md", CorpusScope::Docs),
455            ("src/contest.rs", CorpusScope::Production),
456            ("harness/eval/runner/scoring.rs", CorpusScope::Test),
457            (
458                "harness/eval/fixtures/agent-flow/main.rs",
459                CorpusScope::Fixture,
460            ),
461            (
462                "harness/golden/fixtures/go-basic/main.go",
463                CorpusScope::Fixture,
464            ),
465            ("benches/ask.rs", CorpusScope::Test),
466            ("e2e/smoke.ts", CorpusScope::Test),
467            ("crates/sinter-cli/tests/cli.rs", CorpusScope::Test),
468            ("crates/eval/src/lib.rs", CorpusScope::Production),
469            ("src/eval/mod.rs", CorpusScope::Production),
470            ("src/tests.rs", CorpusScope::Test),
471            ("crates/foo/src/bar/tests.rs", CorpusScope::Test),
472            ("src/bar_tests.rs", CorpusScope::Test),
473            ("src/test_bar.rs", CorpusScope::Test),
474            ("crates/foo/src/bar/tests/cases.rs", CorpusScope::Test),
475            ("src/contests.rs", CorpusScope::Production),
476        ];
477        for (path, expected) in cases {
478            assert_eq!(CorpusScope::classify_path(path), expected, "{path}");
479        }
480    }
481}