1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
14#[serde(rename_all = "snake_case")]
15#[schemars(
16 description = "Structural intent classification. Known variants: browse, focus, collect, process, summarize, analyze, track. Any other string is a custom domain-specific intent."
17)]
18pub enum Intent {
19 Browse,
21 Focus,
23 Collect,
25 Process,
27 Summarize,
29 Analyze,
31 Track,
33 #[serde(untagged)]
35 Custom(String),
36}
37
38impl Intent {
39 pub fn label(&self) -> &str {
43 match self {
44 Intent::Browse => "browse",
45 Intent::Focus => "focus",
46 Intent::Collect => "collect",
47 Intent::Process => "process",
48 Intent::Summarize => "summarize",
49 Intent::Analyze => "analyze",
50 Intent::Track => "track",
51 Intent::Custom(s) => s.as_str(),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
61pub struct IntentScore {
62 pub intent: Intent,
64 pub confidence: f64,
66 pub matching_signals: Vec<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
77#[serde(rename_all = "snake_case")]
78pub enum IntentHint {
79 Primary(Intent),
81 Exclude(Intent),
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
92 fn intent_known_variants_serde_round_trip() {
93 let known = [
94 Intent::Browse,
95 Intent::Focus,
96 Intent::Collect,
97 Intent::Process,
98 Intent::Summarize,
99 Intent::Analyze,
100 Intent::Track,
101 ];
102 for intent in known {
103 let json = serde_json::to_string(&intent).unwrap();
104 let parsed: Intent = serde_json::from_str(&json).unwrap();
105 assert_eq!(intent, parsed);
106 }
107 }
108
109 #[test]
110 fn intent_custom_fallback() {
111 let parsed: Intent = serde_json::from_str(r#""dashboard""#).unwrap();
112 assert_eq!(parsed, Intent::Custom("dashboard".to_string()));
113 }
114
115 #[test]
116 fn intent_custom_round_trip() {
117 let custom = Intent::Custom("my_intent".into());
118 let json = serde_json::to_string(&custom).unwrap();
119 let parsed: Intent = serde_json::from_str(&json).unwrap();
120 assert_eq!(parsed, Intent::Custom("my_intent".into()));
121 }
122
123 #[test]
124 fn intent_known_not_custom() {
125 let parsed: Intent = serde_json::from_str(r#""browse""#).unwrap();
127 assert_eq!(parsed, Intent::Browse);
128 assert_ne!(parsed, Intent::Custom("browse".into()));
129 }
130
131 #[test]
132 fn intent_snake_case_serialization() {
133 assert_eq!(
134 serde_json::to_string(&Intent::Browse).unwrap(),
135 r#""browse""#
136 );
137 assert_eq!(
138 serde_json::to_string(&Intent::Summarize).unwrap(),
139 r#""summarize""#
140 );
141 }
142
143 #[test]
144 fn intent_eq_and_hash() {
145 use std::collections::HashSet;
146 let mut set = HashSet::new();
147 set.insert(Intent::Browse);
148 set.insert(Intent::Browse);
149 set.insert(Intent::Custom("x".into()));
150 assert_eq!(set.len(), 2);
151 }
152
153 #[test]
156 fn intent_score_serde_round_trip() {
157 let score = IntentScore {
158 intent: Intent::Browse,
159 confidence: 0.85,
160 matching_signals: vec!["has_many_relationships".into(), "entity_name_fields".into()],
161 };
162 let json = serde_json::to_string(&score).unwrap();
163 let parsed: IntentScore = serde_json::from_str(&json).unwrap();
164 assert_eq!(score, parsed);
165 }
166
167 #[test]
168 fn intent_score_with_custom_intent() {
169 let score = IntentScore {
170 intent: Intent::Custom("reporting".into()),
171 confidence: 0.6,
172 matching_signals: vec!["date_range_fields".into()],
173 };
174 let json = serde_json::to_string(&score).unwrap();
175 let parsed: IntentScore = serde_json::from_str(&json).unwrap();
176 assert_eq!(score, parsed);
177 }
178
179 #[test]
182 fn intent_hint_primary_serde_round_trip() {
183 let hint = IntentHint::Primary(Intent::Browse);
184 let json = serde_json::to_string(&hint).unwrap();
185 let parsed: IntentHint = serde_json::from_str(&json).unwrap();
186 assert_eq!(hint, parsed);
187 }
188
189 #[test]
190 fn intent_hint_exclude_serde_round_trip() {
191 let hint = IntentHint::Exclude(Intent::Process);
192 let json = serde_json::to_string(&hint).unwrap();
193 let parsed: IntentHint = serde_json::from_str(&json).unwrap();
194 assert_eq!(hint, parsed);
195 }
196
197 #[test]
198 fn intent_hint_json_structure() {
199 let primary = IntentHint::Primary(Intent::Browse);
201 let json = serde_json::to_string(&primary).unwrap();
202 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
203 assert!(value.get("primary").is_some());
204 assert_eq!(value["primary"], "browse");
205
206 let exclude = IntentHint::Exclude(Intent::Process);
208 let json = serde_json::to_string(&exclude).unwrap();
209 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
210 assert!(value.get("exclude").is_some());
211 assert_eq!(value["exclude"], "process");
212 }
213
214 #[test]
215 fn intent_hint_with_custom_intent() {
216 let hint = IntentHint::Primary(Intent::Custom("wizard".into()));
217 let json = serde_json::to_string(&hint).unwrap();
218 let parsed: IntentHint = serde_json::from_str(&json).unwrap();
219 assert_eq!(hint, parsed);
220 }
221
222 #[test]
225 fn intent_json_schema_has_description() {
226 let schema = schemars::schema_for!(Intent);
227 let value = schema.to_value();
228 let desc = value
229 .get("description")
230 .expect("Intent schema must have description");
231 let desc_str = desc.as_str().unwrap();
232 assert!(
233 desc_str.contains("Known variants"),
234 "description should document known variants, got: {desc_str}"
235 );
236 }
237
238 #[test]
239 fn intent_score_json_schema() {
240 let schema = schemars::schema_for!(IntentScore);
241 let value = schema.to_value();
242 let props = value
243 .get("properties")
244 .expect("IntentScore schema must have properties");
245 let obj = props.as_object().unwrap();
246 assert!(obj.contains_key("intent"), "missing 'intent' property");
247 assert!(
248 obj.contains_key("confidence"),
249 "missing 'confidence' property"
250 );
251 assert!(
252 obj.contains_key("matching_signals"),
253 "missing 'matching_signals' property"
254 );
255 }
256
257 #[test]
258 fn intent_hint_json_schema() {
259 let schema = schemars::schema_for!(IntentHint);
260 let value = schema.to_value();
261 let one_of = value.get("oneOf");
263 assert!(one_of.is_some(), "IntentHint schema must have oneOf");
264 }
265
266 #[test]
269 fn intent_score_construction() {
270 let score = IntentScore {
271 intent: Intent::Process,
272 confidence: 0.72,
273 matching_signals: vec!["guarded_transitions".into(), "state_progression".into()],
274 };
275 assert_eq!(score.intent, Intent::Process);
276 assert!((score.confidence - 0.72).abs() < f64::EPSILON);
277 assert_eq!(score.matching_signals.len(), 2);
278 assert_eq!(score.matching_signals[0], "guarded_transitions");
279 assert_eq!(score.matching_signals[1], "state_progression");
280 }
281
282 #[test]
283 fn intent_score_empty_signals() {
284 let score = IntentScore {
285 intent: Intent::Focus,
286 confidence: 0.5,
287 matching_signals: vec![],
288 };
289 let json = serde_json::to_string(&score).unwrap();
290 let parsed: IntentScore = serde_json::from_str(&json).unwrap();
291 assert_eq!(score, parsed);
292 assert!(parsed.matching_signals.is_empty());
293 }
294
295 #[test]
298 fn intent_hint_exclude_custom() {
299 let hint = IntentHint::Exclude(Intent::Custom("niche".into()));
300 let json = serde_json::to_string(&hint).unwrap();
301 let parsed: IntentHint = serde_json::from_str(&json).unwrap();
302 assert_eq!(hint, parsed);
303 }
304
305 #[test]
308 fn intent_eq_known_vs_custom() {
309 assert_ne!(Intent::Browse, Intent::Custom("browse".into()));
311 assert_ne!(Intent::Focus, Intent::Custom("focus".into()));
312 assert_ne!(Intent::Track, Intent::Custom("track".into()));
313 }
314
315 #[test]
318 fn intent_label_known_variants() {
319 assert_eq!(Intent::Browse.label(), "browse");
320 assert_eq!(Intent::Focus.label(), "focus");
321 assert_eq!(Intent::Collect.label(), "collect");
322 assert_eq!(Intent::Process.label(), "process");
323 assert_eq!(Intent::Summarize.label(), "summarize");
324 assert_eq!(Intent::Analyze.label(), "analyze");
325 assert_eq!(Intent::Track.label(), "track");
326 }
327
328 #[test]
329 fn intent_label_custom_returns_inner_string() {
330 assert_eq!(Intent::Custom("reporting".into()).label(), "reporting");
331 }
332}