1use crate::errors::AppError;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, clap::ValueEnum)]
15#[serde(rename_all = "snake_case")]
16#[clap(rename_all = "snake_case")]
17pub enum EntityType {
18 Concept,
19 Date,
20 Dashboard,
21 Decision,
22 File,
23 Incident,
24 IssueTracker,
25 Location,
26 Memory,
27 Organization,
28 Person,
29 Project,
30 Tool,
31}
32
33impl EntityType {
34 pub fn as_str(self) -> &'static str {
36 match self {
37 EntityType::Concept => "concept",
38 EntityType::Date => "date",
39 EntityType::Dashboard => "dashboard",
40 EntityType::Decision => "decision",
41 EntityType::File => "file",
42 EntityType::Incident => "incident",
43 EntityType::IssueTracker => "issue_tracker",
44 EntityType::Location => "location",
45 EntityType::Memory => "memory",
46 EntityType::Organization => "organization",
47 EntityType::Person => "person",
48 EntityType::Project => "project",
49 EntityType::Tool => "tool",
50 }
51 }
52
53 fn parse_exact(key: &str) -> Option<EntityType> {
68 match key {
69 "concept" => Some(EntityType::Concept),
70 "date" => Some(EntityType::Date),
71 "dashboard" => Some(EntityType::Dashboard),
72 "decision" => Some(EntityType::Decision),
73 "file" => Some(EntityType::File),
74 "incident" => Some(EntityType::Incident),
75 "issue_tracker" => Some(EntityType::IssueTracker),
76 "location" => Some(EntityType::Location),
77 "memory" => Some(EntityType::Memory),
78 "organization" => Some(EntityType::Organization),
79 "person" => Some(EntityType::Person),
80 "project" => Some(EntityType::Project),
81 "tool" => Some(EntityType::Tool),
82 _ => None,
83 }
84 }
85
86 pub fn map_to_canonical(s: &str) -> EntityType {
87 let key = s.trim().to_lowercase().replace('-', "_");
88 if let Some(et) = Self::parse_exact(&key) {
90 return et;
91 }
92 match key.as_str() {
93 "platform" | "language" | "feature" | "framework" | "library" | "technology"
95 | "software" | "service" | "product" | "system" | "api" | "component" | "module"
96 | "package" | "dependency" | "protocol" | "standard" | "format" | "algorithm"
97 | "pattern" | "method" | "function" | "class" | "interface" | "command" | "flag"
98 | "option" | "config" | "setting" | "version" | "release" | "model" | "metric"
99 | "topic" | "skill" | "reference" | "note" | "feedback" | "url" | "link"
100 | "keyword" | "tag" | "category" => EntityType::Concept,
101 "document" | "doc" | "artifact" | "directory" | "folder" | "path" | "repository"
103 | "repo" | "codebase" | "script" => EntityType::File,
104 "user" | "author" | "developer" | "maintainer" | "contributor" | "agent" | "owner"
106 | "assignee" => EntityType::Person,
107 "company" | "org" | "vendor" | "group" | "team" | "department" | "institution" => {
109 EntityType::Organization
110 }
111 "bug" | "error" | "failure" | "outage" | "vulnerability" | "cve" | "regression"
113 | "defect" => EntityType::Incident,
114 "adr" | "choice" | "policy" | "ruling" => EntityType::Decision,
116 "time" | "datetime" | "timestamp" | "day" | "month" | "year" | "deadline"
118 | "milestone" => EntityType::Date,
119 "city" | "country" | "region" | "place" | "address" | "site" => EntityType::Location,
121 "ticket" | "issue" | "jira" | "github_issue" | "pr" | "pull_request" => {
123 EntityType::IssueTracker
124 }
125 "panel" | "board" | "view" | "report" | "chart" => EntityType::Dashboard,
127 _ => EntityType::Concept,
129 }
130 }
131}
132
133impl<'de> serde::Deserialize<'de> for EntityType {
138 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
139 where
140 D: serde::Deserializer<'de>,
141 {
142 let s = String::deserialize(deserializer)?;
143 Ok(EntityType::map_to_canonical(&s))
144 }
145}
146
147impl std::fmt::Display for EntityType {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.write_str(self.as_str())
150 }
151}
152
153impl std::str::FromStr for EntityType {
154 type Err = AppError;
155
156 fn from_str(s: &str) -> Result<Self, Self::Err> {
159 Ok(EntityType::map_to_canonical(s))
160 }
161}
162
163impl rusqlite::types::FromSql for EntityType {
164 fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
165 let s = String::column_result(value)?;
166 s.parse::<EntityType>().map_err(|e| {
167 rusqlite::types::FromSqlError::Other(Box::new(std::io::Error::other(e.to_string())))
168 })
169 }
170}
171
172impl rusqlite::types::ToSql for EntityType {
173 fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
174 Ok(rusqlite::types::ToSqlOutput::from(self.as_str()))
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
184 fn deserialize_folds_aliases_and_unknown_to_canonical() {
185 assert_eq!(
186 serde_json::from_str::<EntityType>("\"reference\"").unwrap(),
187 EntityType::Concept
188 );
189 assert_eq!(
190 serde_json::from_str::<EntityType>("\"module\"").unwrap(),
191 EntityType::Concept
192 );
193 assert_eq!(
194 serde_json::from_str::<EntityType>("\"document\"").unwrap(),
195 EntityType::File
196 );
197 assert_eq!(
198 serde_json::from_str::<EntityType>("\"banana\"").unwrap(),
199 EntityType::Concept
200 );
201 }
202
203 #[test]
204 fn deserialize_valid_and_case_insensitive_entity_type() {
205 assert_eq!(
206 serde_json::from_str::<EntityType>("\"issue_tracker\"").unwrap(),
207 EntityType::IssueTracker
208 );
209 assert_eq!(
211 serde_json::from_str::<EntityType>("\"Tool\"").unwrap(),
212 EntityType::Tool
213 );
214 }
215
216 #[test]
217 fn serialize_stays_snake_case() {
218 assert_eq!(
219 serde_json::to_string(&EntityType::IssueTracker).unwrap(),
220 "\"issue_tracker\""
221 );
222 }
223
224 #[test]
225 fn from_str_lowercase_roundtrip() {
226 assert_eq!("person".parse::<EntityType>().unwrap(), EntityType::Person);
227 assert_eq!(
228 "organization".parse::<EntityType>().unwrap(),
229 EntityType::Organization
230 );
231 assert_eq!(
232 "issue_tracker".parse::<EntityType>().unwrap(),
233 EntityType::IssueTracker
234 );
235 }
236
237 #[test]
238 fn from_str_uppercase_is_case_insensitive() {
239 assert_eq!("PERSON".parse::<EntityType>().unwrap(), EntityType::Person);
240 assert_eq!(
241 "Organization".parse::<EntityType>().unwrap(),
242 EntityType::Organization
243 );
244 }
245
246 #[test]
247 fn from_str_unknown_folds_to_concept() {
248 let result = "not-a-real-type".parse::<EntityType>();
249 assert_eq!(result.unwrap(), EntityType::Concept);
250 }
251
252 #[test]
253 fn as_str_returns_canonical_lowercase() {
254 assert_eq!(EntityType::Person.as_str(), "person");
255 assert_eq!(EntityType::IssueTracker.as_str(), "issue_tracker");
256 }
257
258 #[test]
259 fn serde_json_serializes_as_lowercase_string() {
260 let json = serde_json::to_string(&EntityType::Person).unwrap();
261 assert_eq!(json, "\"person\"");
262 let json = serde_json::to_string(&EntityType::IssueTracker).unwrap();
263 assert_eq!(json, "\"issue_tracker\"");
264 }
265
266 #[test]
267 fn serde_json_deserializes_from_lowercase_string() {
268 let et: EntityType = serde_json::from_str("\"person\"").unwrap();
269 assert_eq!(et, EntityType::Person);
270 }
271
272 #[test]
273 fn map_to_canonical_preserves_canonical_types() {
274 assert_eq!(EntityType::map_to_canonical("person"), EntityType::Person);
275 assert_eq!(EntityType::map_to_canonical("concept"), EntityType::Concept);
276 assert_eq!(
277 EntityType::map_to_canonical("issue_tracker"),
278 EntityType::IssueTracker
279 );
280 assert_eq!(
282 EntityType::map_to_canonical("Issue-Tracker"),
283 EntityType::IssueTracker
284 );
285 }
286
287 #[test]
288 fn map_to_canonical_folds_non_canonical_instead_of_discarding() {
289 assert_eq!(
291 EntityType::map_to_canonical("platform"),
292 EntityType::Concept
293 );
294 assert_eq!(
295 EntityType::map_to_canonical("language"),
296 EntityType::Concept
297 );
298 assert_eq!(EntityType::map_to_canonical("feature"), EntityType::Concept);
299 assert_eq!(
301 EntityType::map_to_canonical("developer"),
302 EntityType::Person
303 );
304 assert_eq!(
305 EntityType::map_to_canonical("company"),
306 EntityType::Organization
307 );
308 assert_eq!(EntityType::map_to_canonical("document"), EntityType::File);
309 }
310
311 #[test]
312 fn map_to_canonical_unknown_falls_back_to_concept_never_dropped() {
313 assert_eq!(
314 EntityType::map_to_canonical("totally-made-up-kind"),
315 EntityType::Concept
316 );
317 assert_eq!(EntityType::map_to_canonical(""), EntityType::Concept);
318 }
319}