1use anyhow::{Context, Result};
2use rusqlite::{params, OptionalExtension};
3
4use crate::unit::{AttemptOutcome, Status};
5
6use super::Index;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct DepProviderRow {
10 pub artifact: String,
11 pub unit_id: String,
12 pub unit_title: String,
13 pub status: String,
14 pub description: Option<String>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ChildSummaryRow {
19 pub id: String,
20 pub title: String,
21 pub status: String,
22 pub attempts: usize,
23 pub recent_outcome: Option<String>,
24 pub summary: Option<String>,
25 pub follow_up: Option<String>,
26}
27
28impl Index {
29 pub fn invalid_relevant_diagnostic(&self, unit_id: &str) -> Result<Option<String>> {
30 self.connection()
31 .query_row(
32 r#"
33 SELECT message
34 FROM index_diagnostics
35 WHERE severity = 'error'
36 AND kind IN ('parse', 'schema')
37 AND (
38 unit_id = ?1
39 OR source_path IN (
40 SELECT path FROM source_files WHERE unit_id = ?1 OR path LIKE '%' || ?1 || '-%'
41 )
42 )
43 ORDER BY id
44 LIMIT 1
45 "#,
46 [unit_id],
47 |row| row.get(0),
48 )
49 .optional()
50 .with_context(|| format!("failed to query invalid diagnostics for unit {unit_id}"))
51 }
52
53 pub fn dependency_providers(
54 &self,
55 unit_id: &str,
56 parent_id: Option<&str>,
57 required_artifacts: &[String],
58 ) -> Result<Vec<DepProviderRow>> {
59 if required_artifacts.is_empty() {
60 return Ok(Vec::new());
61 }
62
63 let mut providers = Vec::new();
64 for artifact in required_artifacts {
65 let row = self
66 .connection()
67 .query_row(
68 r#"
69 SELECT u.id, u.title, u.status, u.description
70 FROM unit_artifacts a
71 JOIN units u ON u.id = a.unit_id
72 WHERE a.direction = 'produces'
73 AND a.artifact = ?1
74 AND u.id != ?2
75 AND (?3 IS NULL OR u.parent = ?3)
76 ORDER BY u.id
77 LIMIT 1
78 "#,
79 params![artifact, unit_id, parent_id],
80 |row| {
81 Ok(DepProviderRow {
82 artifact: artifact.clone(),
83 unit_id: row.get(0)?,
84 unit_title: row.get(1)?,
85 status: row.get(2)?,
86 description: row.get(3)?,
87 })
88 },
89 )
90 .optional()?;
91
92 if let Some(row) = row {
93 providers.push(row);
94 }
95 }
96
97 Ok(providers)
98 }
99
100 pub fn child_summaries(&self, parent_id: &str) -> Result<Vec<ChildSummaryRow>> {
101 let mut statement = self.connection().prepare(
102 r#"
103 SELECT id, title, status, notes, close_reason, outputs_json, verify
104 FROM units
105 WHERE parent = ?1
106 ORDER BY id
107 "#,
108 )?;
109 let rows = statement.query_map([parent_id], |row| {
110 let id: String = row.get(0)?;
111 let title: String = row.get(1)?;
112 let status: String = row.get(2)?;
113 let notes: Option<String> = row.get(3)?;
114 let close_reason: Option<String> = row.get(4)?;
115 let outputs_json: Option<String> = row.get(5)?;
116 let verify: Option<String> = row.get(6)?;
117 Ok((id, title, status, notes, close_reason, outputs_json, verify))
118 })?;
119
120 let mut summaries = Vec::new();
121 for row in rows {
122 let (id, title, status, notes, close_reason, outputs_json, verify) = row?;
123 let attempts = self.attempt_count(&id)?;
124 let recent_outcome = self
125 .latest_attempt_outcome(&id)?
126 .or_else(|| status_implied_outcome(&status));
127 let summary = summarize_text(close_reason.as_deref())
128 .or_else(|| summarize_text(notes.as_deref()))
129 .or_else(|| {
130 self.latest_attempt_notes(&id)
131 .ok()
132 .flatten()
133 .and_then(|notes| summarize_text(Some(¬es)))
134 })
135 .or_else(|| summarize_text(outputs_json.as_deref()));
136 let follow_up = self.child_follow_up(&id, &status, verify.as_deref())?;
137
138 summaries.push(ChildSummaryRow {
139 id,
140 title,
141 status,
142 attempts,
143 recent_outcome,
144 summary,
145 follow_up,
146 });
147 }
148
149 summaries.sort_by(|a, b| crate::util::natural_cmp(&a.id, &b.id));
150 Ok(summaries)
151 }
152
153 fn attempt_count(&self, unit_id: &str) -> Result<usize> {
154 let count: i64 = self.connection().query_row(
155 "SELECT COUNT(*) FROM unit_attempts WHERE unit_id = ?1",
156 [unit_id],
157 |row| row.get(0),
158 )?;
159 usize::try_from(count).context("attempt count overflow")
160 }
161
162 fn latest_attempt_outcome(&self, unit_id: &str) -> Result<Option<String>> {
163 self.connection()
164 .query_row(
165 r#"
166 SELECT outcome
167 FROM unit_attempts
168 WHERE unit_id = ?1
169 ORDER BY attempt_index DESC
170 LIMIT 1
171 "#,
172 [unit_id],
173 |row| row.get::<_, Option<String>>(0),
174 )
175 .optional()
176 .map(|value| value.flatten().and_then(parse_attempt_outcome))
177 .context("failed to query latest attempt outcome")
178 }
179
180 fn latest_attempt_notes(&self, unit_id: &str) -> Result<Option<String>> {
181 self.connection()
182 .query_row(
183 r#"
184 SELECT notes
185 FROM unit_attempts
186 WHERE unit_id = ?1 AND notes IS NOT NULL AND TRIM(notes) != ''
187 ORDER BY attempt_index DESC
188 LIMIT 1
189 "#,
190 [unit_id],
191 |row| row.get(0),
192 )
193 .optional()
194 .context("failed to query latest attempt notes")
195 }
196
197 fn child_follow_up(
198 &self,
199 unit_id: &str,
200 status: &str,
201 verify: Option<&str>,
202 ) -> Result<Option<String>> {
203 let decisions: i64 = self.connection().query_row(
204 "SELECT COUNT(*) FROM unit_decisions WHERE unit_id = ?1",
205 [unit_id],
206 |row| row.get(0),
207 )?;
208 if decisions > 0 {
209 return Ok(Some(format!("{} unresolved decision(s)", decisions)));
210 }
211
212 if status != Status::Closed.to_string() {
213 if verify.is_some() {
214 return Ok(Some("still needs completion/verify".to_string()));
215 }
216 return Ok(Some("still open".to_string()));
217 }
218
219 Ok(None)
220 }
221}
222
223fn status_implied_outcome(status: &str) -> Option<String> {
224 if status == Status::Closed.to_string() {
225 Some("success".to_string())
226 } else if status == Status::AwaitingVerify.to_string() {
227 Some("awaiting_verify".to_string())
228 } else if status == Status::InProgress.to_string() {
229 Some("in_progress".to_string())
230 } else {
231 None
232 }
233}
234
235fn parse_attempt_outcome(value: String) -> Option<String> {
236 let outcome: AttemptOutcome = serde_json::from_str(&value).ok()?;
237 Some(match outcome {
238 AttemptOutcome::Success => "success".to_string(),
239 AttemptOutcome::Failed => "failed".to_string(),
240 AttemptOutcome::Abandoned => "abandoned".to_string(),
241 })
242}
243
244fn summarize_text(text: Option<&str>) -> Option<String> {
245 let text = text?.trim();
246 if text.is_empty() {
247 return None;
248 }
249
250 let single_line = text.lines().find(|line| !line.trim().is_empty())?.trim();
251 let mut summary = single_line.chars().take(140).collect::<String>();
252 if single_line.chars().count() > 140 {
253 summary.push('…');
254 }
255 Some(summary)
256}