Skip to main content

mana_core/sqlite/
query.rs

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(&notes)))
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}