Skip to main content

devscout_rs/truth/
identity.rs

1//! Symbol and occurrence identity for the semantic-truth harness.
2//!
3//! A [`SymbolIdentity`] names a member completely enough to survive
4//! serialization, deduplication, graph projection and query joins without
5//! collapsing two distinct overloads or same-line call sites into one
6//! answer. [`OccurrenceSpan`] is a source span, not a bare `(file,
7//! startLine)` pair, so two calls on the same line stay distinct. Neither
8//! type derives an equality a caller could reach for as a shortcut: a
9//! comparator that wants "did this fact match" compares every field it
10//! actually cares about, explicitly.
11
12use serde::{Deserialize, Serialize};
13
14/// The compiled project/target-framework/configuration a fact was produced
15/// under. Distinguishes the same member built for two different targets.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct CompilationIdentity {
19    /// The project (csproj/build unit) name.
20    pub project: String,
21    /// The target framework moniker this compilation was built for.
22    pub tfm: String,
23    /// The build configuration (`Debug`/`Release`).
24    pub configuration: String,
25}
26
27/// A complete symbol identity: assembly, declaring type, member, generic
28/// arity, overload signature, and the compilation it was produced under.
29///
30/// Two identities compare equal only when every field matches -- there is
31/// no partial-identity shortcut.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct SymbolIdentity {
35    /// The declaring assembly's name.
36    pub assembly: String,
37    /// The fully qualified declaring type.
38    pub declaring_type: String,
39    /// The member name.
40    pub member: String,
41    /// The member's own generic arity (0 for a non-generic member).
42    pub generic_arity: u32,
43    /// The overload's parameter-shape signature, distinguishing sibling
44    /// overloads that share a name and arity.
45    pub overload_signature: String,
46    /// The compilation this identity was produced under.
47    pub compilation: CompilationIdentity,
48}
49
50/// A source occurrence, identified by its full span rather than a bare
51/// starting line.
52///
53/// Two calls on one line, or two overloads at different columns of the
54/// same line, stay distinct occurrences under this type.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct OccurrenceSpan {
58    /// The repository-relative source file.
59    pub file: String,
60    /// One-based starting line.
61    pub start_line: u32,
62    /// One-based starting column.
63    pub start_col: u32,
64    /// One-based ending line.
65    pub end_line: u32,
66    /// One-based ending column.
67    pub end_col: u32,
68}
69
70/// The compatibility join key today's shipped `audit.rs` joins facts by:
71/// `(file, startLine, member)`, with no column, end span, or compilation
72/// identity.
73///
74/// This type exists only so a comparator can reproduce that legacy join
75/// deliberately, as a named limitation -- never as a stand-in for
76/// [`SymbolIdentity`]/[`OccurrenceSpan`] equality. It derives neither
77/// `PartialEq` nor `Hash`, so nothing can use it as identity by mistake;
78/// [`CompatibilityJoinKey::joins`] is the one explicit comparison this type
79/// offers.
80#[derive(Debug, Clone)]
81pub struct CompatibilityJoinKey {
82    /// The source file.
83    pub file: String,
84    /// The starting line the legacy join keys off.
85    pub start_line: u32,
86    /// The member name.
87    pub member: String,
88}
89
90impl CompatibilityJoinKey {
91    /// Derives the legacy join key from a full identity and occurrence.
92    pub fn from_identity(identity: &SymbolIdentity, occurrence: &OccurrenceSpan) -> Self {
93        CompatibilityJoinKey {
94            file: occurrence.file.clone(),
95            start_line: occurrence.start_line,
96            member: identity.member.clone(),
97        }
98    }
99
100    /// Whether two occurrences collapse onto the same legacy join key --
101    /// true for two distinct overloads or same-line calls to the same
102    /// member, which is exactly the collapse this harness exists to catch.
103    pub fn joins(&self, other: &CompatibilityJoinKey) -> bool {
104        self.file == other.file
105            && self.start_line == other.start_line
106            && self.member == other.member
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn identity(member: &str, arity: u32, signature: &str) -> SymbolIdentity {
115        SymbolIdentity {
116            assembly: "Fixture".to_string(),
117            declaring_type: "Fixture.Cases".to_string(),
118            member: member.to_string(),
119            generic_arity: arity,
120            overload_signature: signature.to_string(),
121            compilation: CompilationIdentity {
122                project: "Cases".to_string(),
123                tfm: "net8.0".to_string(),
124                configuration: "Release".to_string(),
125            },
126        }
127    }
128
129    fn occurrence(start_col: u32, end_col: u32) -> OccurrenceSpan {
130        OccurrenceSpan {
131            file: "src/Cases.cs".to_string(),
132            start_line: 12,
133            start_col,
134            end_line: 12,
135            end_col,
136        }
137    }
138
139    #[test]
140    fn distinct_overloads_on_one_line_stay_distinct_identities() {
141        let int_call = identity("Ping", 0, "Ping(int)");
142        let string_call = identity("Ping", 0, "Ping(string)");
143        assert_ne!(int_call, string_call);
144        assert_ne!(occurrence(5, 12), occurrence(14, 22));
145    }
146
147    #[test]
148    fn a_symbol_identity_round_trips_through_json_unchanged() {
149        let original = identity("Ping", 0, "Ping(int)");
150        let json = serde_json::to_string(&original).unwrap();
151        let restored: SymbolIdentity = serde_json::from_str(&json).unwrap();
152        assert_eq!(original, restored);
153    }
154
155    #[test]
156    fn an_occurrence_span_round_trips_through_json_unchanged() {
157        let original = occurrence(5, 12);
158        let json = serde_json::to_string(&original).unwrap();
159        let restored: OccurrenceSpan = serde_json::from_str(&json).unwrap();
160        assert_eq!(original, restored);
161    }
162
163    #[test]
164    fn compatibility_join_key_collapses_what_full_identity_keeps_apart() {
165        let int_call = identity("Ping", 0, "Ping(int)");
166        let string_call = identity("Ping", 0, "Ping(string)");
167        let a = CompatibilityJoinKey::from_identity(&int_call, &occurrence(5, 12));
168        let b = CompatibilityJoinKey::from_identity(&string_call, &occurrence(14, 22));
169        assert!(
170            int_call != string_call,
171            "full identity distinguishes the overloads"
172        );
173        assert!(
174            a.joins(&b),
175            "the legacy (file, startLine, member) key does not"
176        );
177    }
178}