Skip to main content

knowledge_runtime/entity/
code_ids.rs

1use crate::ids::{EntityId, ScopeKey};
2use serde::{Deserialize, Serialize};
3
4/// Discriminant for code-entity types.
5#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum CodeEntityKind {
8    Repo,
9    Crate,
10    Module,
11    File,
12    Function,
13    Type,
14    Test,
15    Trait,
16    Impl,
17    Const,
18    Macro,
19}
20
21impl CodeEntityKind {
22    pub fn as_str(&self) -> &'static str {
23        match self {
24            Self::Repo => "repo",
25            Self::Crate => "crate",
26            Self::Module => "module",
27            Self::File => "file",
28            Self::Function => "function",
29            Self::Type => "type",
30            Self::Test => "test",
31            Self::Trait => "trait",
32            Self::Impl => "impl",
33            Self::Const => "const",
34            Self::Macro => "macro",
35        }
36    }
37}
38
39/// A code entity with hierarchical path identity.
40///
41/// The `qualified_path` encodes the full code location, e.g.:
42/// `semantic-memory::src/lib.rs::MemoryStore::search`
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct CodeEntity {
45    /// Entity identity (scope-aware).
46    pub id: EntityId,
47    /// Code entity kind.
48    pub kind: CodeEntityKind,
49    /// Fully qualified path (crate::module::item or file::item).
50    pub qualified_path: String,
51    /// Short name (e.g. function name, type name).
52    pub short_name: String,
53    /// Optional containing file path.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub file_path: Option<String>,
56    /// Optional line number in the file.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub line: Option<u32>,
59}
60
61/// Build a canonical `EntityId` for a code entity with scope context.
62///
63/// Format: `code:{scope_prefix}:{kind}:{qualified_path}`
64///
65/// The scope prefix uses `repo_id` if available, then falls back to
66/// `workspace_id`, then to `namespace`. This ensures the same qualified
67/// path in different repos/workspaces produces different IDs.
68///
69/// This is deterministic — the same inputs always produce the same ID.
70pub fn code_entity_id(kind: &CodeEntityKind, qualified_path: &str, scope: &ScopeKey) -> EntityId {
71    let scope_prefix = scope
72        .repo_id
73        .as_deref()
74        .or(scope.workspace_id.as_deref())
75        .unwrap_or(&scope.namespace);
76
77    EntityId::new(format!(
78        "code:{}:{}:{}",
79        scope_prefix,
80        kind.as_str(),
81        qualified_path
82    ))
83}
84
85/// Extract the display-friendly short path from a code entity ID.
86///
87/// Returns the `kind:qualified_path` portion without the scope prefix,
88/// or `None` if the ID doesn't match the expected format.
89pub fn code_entity_display_path(id: &EntityId) -> Option<String> {
90    let s = id.as_str();
91    let rest = s.strip_prefix("code:")?;
92    // Skip scope_prefix (first segment)
93    let after_scope = rest.find(':').map(|i| &rest[i + 1..])?;
94    Some(after_scope.to_string())
95}
96
97/// Parse a code entity ID back into its components.
98///
99/// Returns `(scope_prefix, kind, qualified_path)` or `None` if the ID
100/// does not match the `code:{scope}:{kind}:{path}` format.
101pub fn parse_code_entity_id(id: &EntityId) -> Option<(String, CodeEntityKind, String)> {
102    let s = id.as_str();
103    let rest = s.strip_prefix("code:")?;
104
105    // Split: scope_prefix:kind:path
106    let first_colon = rest.find(':')?;
107    let scope_prefix = &rest[..first_colon];
108    let after_scope = &rest[first_colon + 1..];
109
110    let second_colon = after_scope.find(':')?;
111    let kind_str = &after_scope[..second_colon];
112    let path = &after_scope[second_colon + 1..];
113
114    let kind = match kind_str {
115        "repo" => CodeEntityKind::Repo,
116        "crate" => CodeEntityKind::Crate,
117        "module" => CodeEntityKind::Module,
118        "file" => CodeEntityKind::File,
119        "function" => CodeEntityKind::Function,
120        "type" => CodeEntityKind::Type,
121        "test" => CodeEntityKind::Test,
122        "trait" => CodeEntityKind::Trait,
123        "impl" => CodeEntityKind::Impl,
124        "const" => CodeEntityKind::Const,
125        "macro" => CodeEntityKind::Macro,
126        _ => return None,
127    };
128
129    Some((scope_prefix.to_string(), kind, path.to_string()))
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn code_id_includes_repo_scope() {
138        let scope = ScopeKey {
139            namespace: "ns".into(),
140            domain: None,
141            workspace_id: None,
142            repo_id: Some("myrepo".into()),
143        };
144        let id = code_entity_id(&CodeEntityKind::Function, "lib::search", &scope);
145        assert_eq!(id.as_str(), "code:myrepo:function:lib::search");
146    }
147
148    #[test]
149    fn code_id_falls_back_to_workspace() {
150        let scope = ScopeKey {
151            namespace: "ns".into(),
152            domain: None,
153            workspace_id: Some("ws1".into()),
154            repo_id: None,
155        };
156        let id = code_entity_id(&CodeEntityKind::Function, "lib::search", &scope);
157        assert_eq!(id.as_str(), "code:ws1:function:lib::search");
158    }
159
160    #[test]
161    fn code_id_falls_back_to_namespace() {
162        let scope = ScopeKey::namespace_only("prod");
163        let id = code_entity_id(&CodeEntityKind::Function, "lib::search", &scope);
164        assert_eq!(id.as_str(), "code:prod:function:lib::search");
165    }
166
167    #[test]
168    fn same_path_different_repos_yields_different_ids() {
169        let scope_a = ScopeKey {
170            namespace: "ns".into(),
171            domain: None,
172            workspace_id: None,
173            repo_id: Some("repo-a".into()),
174        };
175        let scope_b = ScopeKey {
176            namespace: "ns".into(),
177            domain: None,
178            workspace_id: None,
179            repo_id: Some("repo-b".into()),
180        };
181        let id_a = code_entity_id(&CodeEntityKind::Function, "lib::search", &scope_a);
182        let id_b = code_entity_id(&CodeEntityKind::Function, "lib::search", &scope_b);
183        assert_ne!(id_a, id_b);
184    }
185
186    #[test]
187    fn same_scoped_path_remains_stable() {
188        let scope = ScopeKey {
189            namespace: "ns".into(),
190            domain: None,
191            workspace_id: None,
192            repo_id: Some("repo-a".into()),
193        };
194        let id1 = code_entity_id(&CodeEntityKind::Function, "lib::search", &scope);
195        let id2 = code_entity_id(&CodeEntityKind::Function, "lib::search", &scope);
196        assert_eq!(id1, id2);
197    }
198
199    #[test]
200    fn roundtrip_parse() {
201        let scope = ScopeKey {
202            namespace: "ns".into(),
203            domain: None,
204            workspace_id: None,
205            repo_id: Some("myrepo".into()),
206        };
207        let kind = CodeEntityKind::Function;
208        let path = "semantic_memory::lib::MemoryStore::search";
209        let id = code_entity_id(&kind, path, &scope);
210
211        let (parsed_scope, parsed_kind, parsed_path) = parse_code_entity_id(&id).unwrap();
212        assert_eq!(parsed_scope, "myrepo");
213        assert_eq!(parsed_kind, kind);
214        assert_eq!(parsed_path, path);
215    }
216
217    #[test]
218    fn display_path_strips_scope() {
219        let scope = ScopeKey {
220            namespace: "ns".into(),
221            domain: None,
222            workspace_id: None,
223            repo_id: Some("myrepo".into()),
224        };
225        let id = code_entity_id(&CodeEntityKind::Function, "lib::search", &scope);
226        assert_eq!(
227            code_entity_display_path(&id).unwrap(),
228            "function:lib::search"
229        );
230    }
231
232    #[test]
233    fn parse_invalid_id_returns_none() {
234        let bad = EntityId::new("not-a-code-id");
235        assert!(parse_code_entity_id(&bad).is_none());
236    }
237
238    #[test]
239    fn all_kinds_roundtrip() {
240        let scope = ScopeKey::namespace_only("ns");
241        let kinds = [
242            CodeEntityKind::Repo,
243            CodeEntityKind::Crate,
244            CodeEntityKind::Module,
245            CodeEntityKind::File,
246            CodeEntityKind::Function,
247            CodeEntityKind::Type,
248            CodeEntityKind::Test,
249            CodeEntityKind::Trait,
250            CodeEntityKind::Impl,
251            CodeEntityKind::Const,
252            CodeEntityKind::Macro,
253        ];
254        for kind in kinds {
255            let id = code_entity_id(&kind, "test::path", &scope);
256            let (_, parsed, _) = parse_code_entity_id(&id).unwrap();
257            assert_eq!(parsed, kind);
258        }
259    }
260}