1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::commit::Commit;
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum QueryTarget {
14 State,
16 Commits,
18 Intents,
20 Agents,
22 Epochs,
24}
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct QueryFilters {
29 pub path: Option<String>,
31 pub agent_id: Option<String>,
33 pub intent_category: Option<String>,
35 pub tags: Option<Vec<String>>,
37 pub authority_principal: Option<String>,
39 pub reasoning_contains: Option<String>,
41 pub confidence_range: Option<(f64, f64)>,
43 pub intent_status: Option<String>,
45 pub outcome: Option<String>,
47 pub date_from: Option<DateTime<Utc>>,
49 pub date_to: Option<DateTime<Utc>>,
50 pub has_deviations: Option<bool>,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct QueryOptions {
57 pub limit: Option<usize>,
59 pub offset: Option<usize>,
61 pub order_by: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Query {
68 pub target: QueryTarget,
69 pub ref_name: Option<String>,
70 pub filters: QueryFilters,
71 pub options: QueryOptions,
72}
73
74pub fn filter_commits(commits: &[Commit], filters: &QueryFilters) -> Vec<Commit> {
76 commits
77 .iter()
78 .filter(|c| matches_filters(c, filters))
79 .cloned()
80 .collect()
81}
82
83pub fn matches_filters(commit: &Commit, filters: &QueryFilters) -> bool {
85 if let Some(ref agent) = filters.agent_id
87 && &commit.agent_id != agent
88 {
89 return false;
90 }
91
92 if let Some(ref category) = filters.intent_category {
94 let commit_cat = format!("{:?}", commit.intent.category);
95 if !commit_cat.eq_ignore_ascii_case(category) {
96 return false;
97 }
98 }
99
100 if let Some(ref tags) = filters.tags {
102 for tag in tags {
103 if !commit.intent.tags.contains(tag) {
104 return false;
105 }
106 }
107 }
108
109 if let Some(ref principal) = filters.authority_principal
111 && &commit.authority.principal != principal
112 {
113 return false;
114 }
115
116 if let Some(ref query) = filters.reasoning_contains {
118 let query_lower = query.to_lowercase();
119 let matches = commit
120 .reasoning
121 .as_ref()
122 .map(|r| r.to_lowercase().contains(&query_lower))
123 .unwrap_or(false)
124 || commit
125 .intent
126 .description
127 .to_lowercase()
128 .contains(&query_lower);
129 if !matches {
130 return false;
131 }
132 }
133
134 if let Some((min, max)) = filters.confidence_range {
136 match commit.confidence {
137 Some(c) if c >= min && c <= max => {}
138 Some(_) => return false,
139 None => return false,
140 }
141 }
142
143 if let Some(ref status) = filters.intent_status {
145 let commit_status = format!("{:?}", commit.intent.lifecycle.status);
146 if !commit_status.eq_ignore_ascii_case(status) {
147 return false;
148 }
149 }
150
151 if let Some(from) = filters.date_from
153 && commit.timestamp < from
154 {
155 return false;
156 }
157 if let Some(to) = filters.date_to
158 && commit.timestamp > to
159 {
160 return false;
161 }
162
163 if let Some(true) = filters.has_deviations {
165 let has = commit
166 .intent
167 .lifecycle
168 .resolution
169 .as_ref()
170 .map(|r| !r.deviations.is_empty())
171 .unwrap_or(false);
172 if !has {
173 return false;
174 }
175 }
176
177 true
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct BlameEntry {
183 pub path: String,
184 pub commit_id: String,
185 pub agent_id: String,
186 pub intent_category: String,
187 pub intent_description: String,
188 pub reasoning: Option<String>,
189 pub timestamp: DateTime<Utc>,
190 #[serde(default)]
197 pub timestamp_anomaly: bool,
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::commit::CommitBuilder;
204 use crate::intent::{Authority, Intent, IntentCategory};
205 use crate::object::ObjectId;
206
207 fn test_commit(agent: &str, category: IntentCategory, desc: &str) -> Commit {
208 CommitBuilder::new(
209 ObjectId::hash(b"state"),
210 agent,
211 Authority::simple(agent),
212 Intent::new(category, desc),
213 )
214 .build()
215 }
216
217 fn test_commit_with_reasoning(agent: &str, desc: &str, reasoning: &str) -> Commit {
218 CommitBuilder::new(
219 ObjectId::hash(b"state"),
220 agent,
221 Authority::simple(agent),
222 Intent::new(IntentCategory::Explore, desc),
223 )
224 .reasoning(reasoning)
225 .confidence(0.8)
226 .build()
227 }
228
229 #[test]
230 fn test_filter_by_agent() {
231 let commits = vec![
232 test_commit("agent/a", IntentCategory::Explore, "by a"),
233 test_commit("agent/b", IntentCategory::Explore, "by b"),
234 test_commit("agent/a", IntentCategory::Fix, "fix by a"),
235 ];
236
237 let filtered = filter_commits(
238 &commits,
239 &QueryFilters {
240 agent_id: Some("agent/a".to_string()),
241 ..Default::default()
242 },
243 );
244 assert_eq!(filtered.len(), 2);
245 }
246
247 #[test]
248 fn test_filter_by_category() {
249 let commits = vec![
250 test_commit("agent/a", IntentCategory::Explore, "explore"),
251 test_commit("agent/a", IntentCategory::Fix, "fix"),
252 test_commit("agent/a", IntentCategory::Explore, "explore 2"),
253 ];
254
255 let filtered = filter_commits(
256 &commits,
257 &QueryFilters {
258 intent_category: Some("Explore".to_string()),
259 ..Default::default()
260 },
261 );
262 assert_eq!(filtered.len(), 2);
263 }
264
265 #[test]
266 fn test_filter_by_reasoning_contains() {
267 let commits = vec![
268 test_commit_with_reasoning("a", "storage", "NFS is better for small clusters"),
269 test_commit_with_reasoning("a", "network", "10GbE bonding configured"),
270 test_commit_with_reasoning("a", "gpu", "Memory controller issue on node 3"),
271 ];
272
273 let filtered = filter_commits(
274 &commits,
275 &QueryFilters {
276 reasoning_contains: Some("memory controller".to_string()),
277 ..Default::default()
278 },
279 );
280 assert_eq!(filtered.len(), 1);
281 assert!(filtered[0].intent.description.contains("gpu"));
282 }
283
284 #[test]
285 fn test_filter_by_confidence_range() {
286 let commits = vec![
287 {
288 let mut c = test_commit("a", IntentCategory::Explore, "high");
289 c.confidence = Some(0.9);
290 c
291 },
292 {
293 let mut c = test_commit("a", IntentCategory::Explore, "low");
294 c.confidence = Some(0.3);
295 c
296 },
297 {
298 let mut c = test_commit("a", IntentCategory::Explore, "mid");
299 c.confidence = Some(0.6);
300 c
301 },
302 ];
303
304 let filtered = filter_commits(
305 &commits,
306 &QueryFilters {
307 confidence_range: Some((0.0, 0.5)),
308 ..Default::default()
309 },
310 );
311 assert_eq!(filtered.len(), 1);
312 assert_eq!(filtered[0].intent.description, "low");
313 }
314
315 #[test]
316 fn test_combined_filters() {
317 let commits = vec![
318 test_commit_with_reasoning("agent/planner", "storage explore", "trying NFS"),
319 test_commit_with_reasoning("agent/planner", "network fix", "fixing DNS"),
320 test_commit_with_reasoning("agent/monitor", "health check", "node healthy"),
321 ];
322
323 let filtered = filter_commits(
324 &commits,
325 &QueryFilters {
326 agent_id: Some("agent/planner".to_string()),
327 reasoning_contains: Some("NFS".to_string()),
328 ..Default::default()
329 },
330 );
331 assert_eq!(filtered.len(), 1);
332 }
333
334 #[test]
337 fn test_empty_filters_returns_all() {
338 let commits = vec![
339 test_commit("a", IntentCategory::Explore, "x"),
340 test_commit("b", IntentCategory::Fix, "y"),
341 test_commit("c", IntentCategory::Checkpoint, "z"),
342 ];
343 let filtered = filter_commits(&commits, &QueryFilters::default());
344 assert_eq!(filtered.len(), 3);
345 }
346
347 #[test]
350 fn test_filter_by_tags_all_must_match() {
351 use crate::intent::Intent;
352 let make = |tags: &[&str]| {
353 CommitBuilder::new(
354 ObjectId::hash(b"s"),
355 "a",
356 Authority::simple("a"),
357 Intent::new(IntentCategory::Explore, "x")
358 .with_tags(tags.iter().map(|s| s.to_string()).collect()),
359 )
360 .build()
361 };
362
363 let commits = vec![
364 make(&["gpu", "node-3"]),
365 make(&["gpu"]),
366 make(&["cpu", "node-3"]),
367 ];
368
369 let filtered = filter_commits(
371 &commits,
372 &QueryFilters {
373 tags: Some(vec!["gpu".to_string(), "node-3".to_string()]),
374 ..Default::default()
375 },
376 );
377 assert_eq!(filtered.len(), 1);
378 }
379
380 #[test]
383 fn test_filter_by_authority_principal() {
384 let commits = vec![
385 CommitBuilder::new(
386 ObjectId::hash(b"s"),
387 "a",
388 Authority::simple("human/alice"),
389 Intent::new(IntentCategory::Explore, "by alice"),
390 )
391 .build(),
392 CommitBuilder::new(
393 ObjectId::hash(b"s"),
394 "a",
395 Authority::simple("human/bob"),
396 Intent::new(IntentCategory::Explore, "by bob"),
397 )
398 .build(),
399 ];
400
401 let filtered = filter_commits(
402 &commits,
403 &QueryFilters {
404 authority_principal: Some("human/alice".to_string()),
405 ..Default::default()
406 },
407 );
408 assert_eq!(filtered.len(), 1);
409 assert!(filtered[0].intent.description.contains("alice"));
410 }
411
412 #[test]
415 fn test_confidence_range_excludes_none_confidence() {
416 let commits = vec![
417 {
418 let mut c = test_commit("a", IntentCategory::Explore, "has confidence");
419 c.confidence = Some(0.8);
420 c
421 },
422 test_commit("a", IntentCategory::Explore, "no confidence"),
424 ];
425
426 let filtered = filter_commits(
427 &commits,
428 &QueryFilters {
429 confidence_range: Some((0.0, 1.0)),
430 ..Default::default()
431 },
432 );
433 assert_eq!(filtered.len(), 1);
434 assert_eq!(filtered[0].intent.description, "has confidence");
435 }
436
437 #[test]
440 fn test_category_filter_case_insensitive() {
441 let commits = vec![
442 test_commit("a", IntentCategory::Explore, "x"),
443 test_commit("a", IntentCategory::Fix, "y"),
444 ];
445
446 let filtered = filter_commits(
448 &commits,
449 &QueryFilters {
450 intent_category: Some("explore".to_string()),
451 ..Default::default()
452 },
453 );
454 assert_eq!(filtered.len(), 1);
455 }
456
457 #[test]
460 fn test_filter_by_date_range() {
461 use chrono::Duration;
462 let now = Utc::now();
463
464 let mut past = test_commit("a", IntentCategory::Explore, "old");
465 past.timestamp = now - Duration::days(10);
466
467 let mut recent = test_commit("a", IntentCategory::Explore, "recent");
468 recent.timestamp = now - Duration::days(1);
469
470 let mut future = test_commit("a", IntentCategory::Explore, "future");
471 future.timestamp = now + Duration::days(1);
472
473 let commits = vec![past.clone(), recent.clone(), future.clone()];
474
475 let from = now - Duration::days(7);
477 let to = now + Duration::minutes(1);
478
479 let filtered = filter_commits(
480 &commits,
481 &QueryFilters {
482 date_from: Some(from),
483 date_to: Some(to),
484 ..Default::default()
485 },
486 );
487 assert_eq!(filtered.len(), 1);
488 assert_eq!(filtered[0].intent.description, "recent");
489 }
490
491 #[test]
494 fn test_filter_by_has_deviations() {
495 use crate::intent::{Deviation, DeviationImpact, Intent, Outcome, Resolution};
496
497 let make_with_deviation = |desc: &str| {
498 let mut intent = Intent::new(IntentCategory::Explore, desc);
499 intent.lifecycle.resolution = Some(Resolution {
500 summary: "completed with deviation".into(),
501 outcome: Outcome::PartiallyFulfilled,
502 deviations: vec![Deviation {
503 description: "unexpected value".into(),
504 reason: "state diverged".into(),
505 impact: DeviationImpact::Medium,
506 follow_up: None,
507 }],
508 commits: Vec::new(),
509 branches_explored: Vec::new(),
510 confidence: 0.7,
511 });
512 CommitBuilder::new(
513 ObjectId::hash(desc.as_bytes()),
514 "a",
515 Authority::simple("a"),
516 intent,
517 )
518 .build()
519 };
520
521 let commits = vec![
522 make_with_deviation("has deviation"),
523 test_commit("a", IntentCategory::Explore, "no deviation"),
524 ];
525
526 let filtered = filter_commits(
527 &commits,
528 &QueryFilters {
529 has_deviations: Some(true),
530 ..Default::default()
531 },
532 );
533 assert_eq!(filtered.len(), 1);
534 assert_eq!(filtered[0].intent.description, "has deviation");
535 }
536
537 #[test]
540 fn test_query_filters_serializes_and_deserializes() {
541 let f = QueryFilters {
542 path: Some("/nodes/*".into()),
543 agent_id: Some("agent/a".into()),
544 confidence_range: Some((0.5, 0.9)),
545 ..Default::default()
546 };
547 let json = serde_json::to_string(&f).unwrap();
548 let back: QueryFilters = serde_json::from_str(&json).unwrap();
549 assert_eq!(back.path.as_deref(), Some("/nodes/*"));
550 assert_eq!(back.confidence_range, Some((0.5, 0.9)));
551 }
552
553 #[test]
554 fn test_query_target_round_trips() {
555 for t in [
556 QueryTarget::State,
557 QueryTarget::Commits,
558 QueryTarget::Intents,
559 QueryTarget::Agents,
560 QueryTarget::Epochs,
561 ] {
562 let j = serde_json::to_value(&t).unwrap();
563 let back: QueryTarget = serde_json::from_value(j).unwrap();
564 assert_eq!(back, t);
565 }
566 }
567}