1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13pub struct AskReport {
14 pub conversation: String,
16 pub turn: u32,
18 pub question: String,
20 pub chips: Vec<Chip>,
22 pub reads: Vec<Read>,
24 pub answer: String,
26 pub citations: Vec<Citation>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub proposal: Option<Proposal>,
31 pub usage: AskUsage,
33 pub end: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub reason: Option<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub context: Option<crate::context::ContextManifest>,
42}
43
44#[derive(
46 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
47)]
48pub struct AskUsage {
49 pub input: u64,
51 pub output: u64,
53 pub cache_read: u64,
55}
56
57impl AskUsage {
58 #[must_use]
60 pub const fn billed(&self) -> u64 {
61 self.input + self.output
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
67pub struct Chip {
68 pub id: String,
70 pub kind: String,
73 pub label: String,
75 pub reference: serde_json::Value,
77 pub digest: String,
79 #[serde(default)]
81 pub from_view: bool,
82 #[serde(default)]
84 pub changed: bool,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
89pub struct Read {
90 pub id: String,
92 pub tool: String,
94 pub target: String,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub path: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub start: Option<u32>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub end: Option<u32>,
105 pub digest: String,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub content_sha256: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub subject: Option<String>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
118pub struct Citation {
119 pub marker: String,
121 pub id: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub lines: Option<(u32, u32)>,
126 pub state: String,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub path: Option<String>,
131 pub label: String,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
137pub struct Proposal {
138 pub kind: String,
140 pub title: String,
142 pub fields: ProposalFields,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub runnable: Option<crate::actions::Runnable>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub input: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub blocked: Option<String>,
153 pub command: String,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub conversation: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub turn: Option<u32>,
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
165pub struct ProposalFields {
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub target: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub request: Option<String>,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub checks: Vec<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub note: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub answer: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub description: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub symptom: Option<String>,
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub expected: Vec<String>,
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
192 pub unchanged: Vec<String>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
197pub struct Conversation {
198 pub id: String,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub feature: Option<String>,
203 pub title: String,
205 pub at: u64,
207 pub turns: u32,
209 pub open: bool,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
217pub struct AskConversations {
218 pub conversations: Vec<Conversation>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
224pub struct AskThread {
225 pub conversation: Conversation,
227 pub turns: Vec<AskReport>,
229 #[serde(default)]
231 pub segment: u32,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub run: Option<String>,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub results: Vec<AskResult>,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
242pub struct AskResult {
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub turn: Option<u32>,
246 pub kind: String,
248 pub unit: String,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub requirement: Option<String>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub task: Option<String>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct Marker {
261 pub text: String,
263 pub id: String,
265 pub lines: Option<(u32, u32)>,
267}
268
269#[must_use]
271pub fn markers(answer: &str) -> Vec<Marker> {
272 let mut out = Vec::new();
273 let mut rest = answer;
274 while let Some(open) = rest.find('[') {
275 rest = &rest[open + 1..];
276 let Some(close) = rest.find(']') else { break };
277 let inner = &rest[..close];
278 if let Some(m) = marker(inner) {
279 out.push(m);
280 }
281 rest = &rest[close + 1..];
282 }
283 out
284}
285
286fn marker(inner: &str) -> Option<Marker> {
287 let (id, lines) = match inner.split_once(':') {
288 Some((id, range)) => {
289 let (a, b) = range.split_once('-').unwrap_or((range, range));
290 let (a, b) = (a.trim().parse().ok()?, b.trim().parse().ok()?);
291 (id, Some((a, b)))
292 }
293 None => (inner, None),
294 };
295 let mut chars = id.chars();
296 let kind = chars.next()?;
297 if !matches!(kind, 'r' | 'c') || id.len() < 2 || !chars.all(|c| c.is_ascii_digit()) {
298 return None;
299 }
300 Some(Marker {
301 text: format!("[{inner}]"),
302 id: id.to_owned(),
303 lines,
304 })
305}
306
307#[must_use]
311pub fn check(answer: &str, reads: &[Read], chips: &[Chip]) -> Vec<Citation> {
312 let mut out: Vec<Citation> = Vec::new();
313 for m in markers(answer) {
314 if out.iter().any(|c| c.marker == m.text) {
315 continue;
316 }
317 let read = reads.iter().find(|r| r.id == m.id);
318 let chip = chips.iter().find(|c| c.id == m.id);
319 let (state, path, label) = match (read, chip) {
320 (Some(r), _) => {
321 let inside = match (m.lines, r.start, r.end) {
322 (None, ..) => true,
323 (Some((a, b)), Some(s), Some(e)) => a <= b && a >= s && b <= e,
324 (Some(_), ..) => false,
326 };
327 let label = match (&r.path, m.lines.or(r.start.zip(r.end))) {
328 (Some(p), Some((a, b))) if a == b => format!("{p} {a}"),
329 (Some(p), Some((a, b))) => format!("{p} {a}–{b}"),
330 (Some(p), None) => p.clone(),
331 (None, _) => r.target.clone(),
332 };
333 (inside, r.path.clone(), label)
334 }
335 (None, Some(c)) => {
336 let at = |k: &str| c.reference[k].as_u64().and_then(|n| u32::try_from(n).ok());
338 let inside = match m.lines {
339 None => true,
340 Some((a, b)) if c.kind == "file_range" => {
341 a <= b && a >= at("start").unwrap_or(1) && at("end").is_none_or(|e| b <= e)
342 }
343 Some(_) => false,
344 };
345 (
346 inside,
347 c.reference["path"].as_str().map(str::to_owned),
348 c.label.clone(),
349 )
350 }
351 (None, None) => (false, None, m.text.clone()),
352 };
353 out.push(Citation {
354 marker: m.text,
355 id: m.id,
356 lines: m.lines,
357 state: if state { "read" } else { "not_read" }.to_owned(),
358 path,
359 label,
360 });
361 }
362 out
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 fn read(id: &str, path: &str, start: u32, end: u32) -> Read {
370 Read {
371 id: id.to_owned(),
372 tool: "read_file".to_owned(),
373 target: path.to_owned(),
374 path: Some(path.to_owned()),
375 start: Some(start),
376 end: Some(end),
377 digest: "d".to_owned(),
378 content_sha256: None,
379 subject: None,
380 }
381 }
382
383 #[test]
384 fn markers_are_ids_the_engine_issues_and_nothing_else() {
385 let found: Vec<_> = markers("see [r3], [r12:4-9], [c1:2], [FR-001], [x] and [r]")
386 .into_iter()
387 .map(|m| (m.id, m.lines))
388 .collect();
389 assert_eq!(
390 found,
391 [
392 ("r3".to_owned(), None),
393 ("r12".to_owned(), Some((4, 9))),
394 ("c1".to_owned(), Some((2, 2))),
395 ]
396 );
397 }
398
399 #[test]
402 fn a_citation_counts_only_inside_a_read_of_the_turn() {
403 let reads = [read("r1", "src/a.rs", 10, 40)];
404 let states: Vec<_> = check("[r1] [r1:12-20] [r1:5-12] [r9] [r1:20-12]", &reads, &[])
405 .into_iter()
406 .map(|c| (c.marker, c.state, c.label))
407 .collect();
408 assert_eq!(
409 states,
410 [
411 ("[r1]".into(), "read".into(), "src/a.rs 10–40".into()),
412 ("[r1:12-20]".into(), "read".into(), "src/a.rs 12–20".into()),
413 (
414 "[r1:5-12]".into(),
415 "not_read".into(),
416 "src/a.rs 5–12".into()
417 ),
418 ("[r9]".into(), "not_read".into(), "[r9]".into()),
419 (
420 "[r1:20-12]".into(),
421 "not_read".into(),
422 "src/a.rs 20–12".into()
423 ),
424 ]
425 );
426 }
427
428 #[test]
429 fn a_chip_is_citable_like_a_read() {
430 let chip = Chip {
431 id: "c1".to_owned(),
432 kind: "task".to_owned(),
433 label: "T004 Handle Unicode".to_owned(),
434 reference: serde_json::json!({ "task": "T004" }),
435 digest: "d".to_owned(),
436 from_view: true,
437 changed: false,
438 };
439 let c = check("as [c1] says, not [c1:3]", &[], &[chip]);
440 assert_eq!(
441 (c[0].state.as_str(), c[0].label.as_str()),
442 ("read", "T004 Handle Unicode")
443 );
444 assert_eq!(c[1].state, "not_read", "a task chip has no lines");
445 let file = Chip {
447 id: "c2".to_owned(),
448 kind: "file_range".to_owned(),
449 label: "src/a.rs 10–20".to_owned(),
450 reference: serde_json::json!({ "path": "src/a.rs", "start": 10, "end": 20 }),
451 digest: "d".to_owned(),
452 from_view: false,
453 changed: false,
454 };
455 let c = check("[c2:12-14] and [c2:500-600]", &[], &[file]);
456 assert_eq!(
457 (c[0].state.as_str(), c[1].state.as_str()),
458 ("read", "not_read")
459 );
460 }
461}