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
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
43pub struct IntentScore {
44 pub intent: Intent,
46 pub confidence: f64,
48 pub matching_signals: Vec<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
59#[serde(rename_all = "snake_case")]
60pub enum IntentHint {
61 Primary(Intent),
63 Exclude(Intent),
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70
71 #[test]
74 fn intent_known_variants_serde_round_trip() {
75 let known = [
76 Intent::Browse,
77 Intent::Focus,
78 Intent::Collect,
79 Intent::Process,
80 Intent::Summarize,
81 Intent::Analyze,
82 Intent::Track,
83 ];
84 for intent in known {
85 let json = serde_json::to_string(&intent).unwrap();
86 let parsed: Intent = serde_json::from_str(&json).unwrap();
87 assert_eq!(intent, parsed);
88 }
89 }
90
91 #[test]
92 fn intent_custom_fallback() {
93 let parsed: Intent = serde_json::from_str(r#""dashboard""#).unwrap();
94 assert_eq!(parsed, Intent::Custom("dashboard".to_string()));
95 }
96
97 #[test]
98 fn intent_custom_round_trip() {
99 let custom = Intent::Custom("my_intent".into());
100 let json = serde_json::to_string(&custom).unwrap();
101 let parsed: Intent = serde_json::from_str(&json).unwrap();
102 assert_eq!(parsed, Intent::Custom("my_intent".into()));
103 }
104
105 #[test]
106 fn intent_known_not_custom() {
107 let parsed: Intent = serde_json::from_str(r#""browse""#).unwrap();
109 assert_eq!(parsed, Intent::Browse);
110 assert_ne!(parsed, Intent::Custom("browse".into()));
111 }
112
113 #[test]
114 fn intent_snake_case_serialization() {
115 assert_eq!(
116 serde_json::to_string(&Intent::Browse).unwrap(),
117 r#""browse""#
118 );
119 assert_eq!(
120 serde_json::to_string(&Intent::Summarize).unwrap(),
121 r#""summarize""#
122 );
123 }
124
125 #[test]
126 fn intent_eq_and_hash() {
127 use std::collections::HashSet;
128 let mut set = HashSet::new();
129 set.insert(Intent::Browse);
130 set.insert(Intent::Browse);
131 set.insert(Intent::Custom("x".into()));
132 assert_eq!(set.len(), 2);
133 }
134
135 #[test]
138 fn intent_score_serde_round_trip() {
139 let score = IntentScore {
140 intent: Intent::Browse,
141 confidence: 0.85,
142 matching_signals: vec!["has_many_relationships".into(), "entity_name_fields".into()],
143 };
144 let json = serde_json::to_string(&score).unwrap();
145 let parsed: IntentScore = serde_json::from_str(&json).unwrap();
146 assert_eq!(score, parsed);
147 }
148
149 #[test]
150 fn intent_score_with_custom_intent() {
151 let score = IntentScore {
152 intent: Intent::Custom("reporting".into()),
153 confidence: 0.6,
154 matching_signals: vec!["date_range_fields".into()],
155 };
156 let json = serde_json::to_string(&score).unwrap();
157 let parsed: IntentScore = serde_json::from_str(&json).unwrap();
158 assert_eq!(score, parsed);
159 }
160
161 #[test]
164 fn intent_hint_primary_serde_round_trip() {
165 let hint = IntentHint::Primary(Intent::Browse);
166 let json = serde_json::to_string(&hint).unwrap();
167 let parsed: IntentHint = serde_json::from_str(&json).unwrap();
168 assert_eq!(hint, parsed);
169 }
170
171 #[test]
172 fn intent_hint_exclude_serde_round_trip() {
173 let hint = IntentHint::Exclude(Intent::Process);
174 let json = serde_json::to_string(&hint).unwrap();
175 let parsed: IntentHint = serde_json::from_str(&json).unwrap();
176 assert_eq!(hint, parsed);
177 }
178
179 #[test]
180 fn intent_hint_json_structure() {
181 let primary = IntentHint::Primary(Intent::Browse);
183 let json = serde_json::to_string(&primary).unwrap();
184 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
185 assert!(value.get("primary").is_some());
186 assert_eq!(value["primary"], "browse");
187
188 let exclude = IntentHint::Exclude(Intent::Process);
190 let json = serde_json::to_string(&exclude).unwrap();
191 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
192 assert!(value.get("exclude").is_some());
193 assert_eq!(value["exclude"], "process");
194 }
195
196 #[test]
197 fn intent_hint_with_custom_intent() {
198 let hint = IntentHint::Primary(Intent::Custom("wizard".into()));
199 let json = serde_json::to_string(&hint).unwrap();
200 let parsed: IntentHint = serde_json::from_str(&json).unwrap();
201 assert_eq!(hint, parsed);
202 }
203
204 #[test]
207 fn intent_json_schema_has_description() {
208 let schema = schemars::schema_for!(Intent);
209 let value = schema.to_value();
210 let desc = value
211 .get("description")
212 .expect("Intent schema must have description");
213 let desc_str = desc.as_str().unwrap();
214 assert!(
215 desc_str.contains("Known variants"),
216 "description should document known variants, got: {desc_str}"
217 );
218 }
219
220 #[test]
221 fn intent_score_json_schema() {
222 let schema = schemars::schema_for!(IntentScore);
223 let value = schema.to_value();
224 let props = value
225 .get("properties")
226 .expect("IntentScore schema must have properties");
227 let obj = props.as_object().unwrap();
228 assert!(obj.contains_key("intent"), "missing 'intent' property");
229 assert!(
230 obj.contains_key("confidence"),
231 "missing 'confidence' property"
232 );
233 assert!(
234 obj.contains_key("matching_signals"),
235 "missing 'matching_signals' property"
236 );
237 }
238
239 #[test]
240 fn intent_hint_json_schema() {
241 let schema = schemars::schema_for!(IntentHint);
242 let value = schema.to_value();
243 let one_of = value.get("oneOf");
245 assert!(one_of.is_some(), "IntentHint schema must have oneOf");
246 }
247
248 #[test]
251 fn intent_score_construction() {
252 let score = IntentScore {
253 intent: Intent::Process,
254 confidence: 0.72,
255 matching_signals: vec!["guarded_transitions".into(), "state_progression".into()],
256 };
257 assert_eq!(score.intent, Intent::Process);
258 assert!((score.confidence - 0.72).abs() < f64::EPSILON);
259 assert_eq!(score.matching_signals.len(), 2);
260 assert_eq!(score.matching_signals[0], "guarded_transitions");
261 assert_eq!(score.matching_signals[1], "state_progression");
262 }
263
264 #[test]
265 fn intent_score_empty_signals() {
266 let score = IntentScore {
267 intent: Intent::Focus,
268 confidence: 0.5,
269 matching_signals: vec![],
270 };
271 let json = serde_json::to_string(&score).unwrap();
272 let parsed: IntentScore = serde_json::from_str(&json).unwrap();
273 assert_eq!(score, parsed);
274 assert!(parsed.matching_signals.is_empty());
275 }
276
277 #[test]
280 fn intent_hint_exclude_custom() {
281 let hint = IntentHint::Exclude(Intent::Custom("niche".into()));
282 let json = serde_json::to_string(&hint).unwrap();
283 let parsed: IntentHint = serde_json::from_str(&json).unwrap();
284 assert_eq!(hint, parsed);
285 }
286
287 #[test]
290 fn intent_eq_known_vs_custom() {
291 assert_ne!(Intent::Browse, Intent::Custom("browse".into()));
293 assert_ne!(Intent::Focus, Intent::Custom("focus".into()));
294 assert_ne!(Intent::Track, Intent::Custom("track".into()));
295 }
296}