1use std::{
2 collections::BTreeMap,
3 path::{Path, PathBuf},
4};
5
6use rusqlite::params;
7use serde::Serialize;
8use tokio_rusqlite::Connection;
9
10use super::{
11 DATABASE,
12 receipts::{Error, check_files, schema_initialized},
13};
14use crate::workspace::{canonical_target, outside_workspace};
15
16#[derive(Debug, Clone, Serialize)]
17#[serde(rename_all = "camelCase")]
18pub struct RunSummary {
19 pub id: crate::types::RunId,
20 #[serde(serialize_with = "crate::platform::path_serde::serialize")]
21 pub repo_path: PathBuf,
22 pub created_at: crate::types::Timestamp,
23 pub completed_at: Option<crate::types::Timestamp>,
24 pub status: crate::types::RunStatus,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub comment: Option<crate::types::RunComment>,
28 pub counts: BTreeMap<crate::types::RequestStatus, u64>,
29 #[serde(skip_serializing_if = "Vec::is_empty")]
32 pub unreadable: Vec<super::Unreadable>,
33}
34
35impl RunSummary {
36 pub fn readable(&self) -> bool {
38 !self.unreadable.iter().any(|record| record.kind == "run")
39 }
40}
41
42pub async fn read_runs(
46 state: &Path,
47 repo: Option<&Path>,
48 limit: u32,
49 offset: u32,
50) -> Result<Vec<RunSummary>, String> {
51 let repos = repo.map(|path| vec![path.to_path_buf()]);
52 read_scoped_runs(state, repos.as_deref(), limit, offset).await
53}
54
55pub async fn read_scoped_runs(
57 state: &Path,
58 repos: Option<&[PathBuf]>,
59 limit: u32,
60 offset: u32,
61) -> Result<Vec<RunSummary>, String> {
62 let state = canonical_target(state).map_err(|e| e.to_string())?;
63 let repos = repos
64 .map(|paths| {
65 paths
66 .iter()
67 .map(|path| {
68 let path = canonical_target(path).map_err(|error| error.to_string())?;
69 outside_workspace(&path, &state).map_err(|error| error.to_string())?;
70 Ok(crate::platform::path_text(&path))
71 })
72 .collect::<Result<Vec<_>, String>>()
73 })
74 .transpose()?;
75 let repos = repos
76 .map(|paths| serde_json::to_string(&paths))
77 .transpose()
78 .map_err(|error| error.to_string())?;
79 check_files(&state)?;
80 if !state
81 .join(DATABASE)
82 .try_exists()
83 .map_err(|e| e.to_string())?
84 {
85 return Ok(Vec::new());
86 }
87 let connection = Connection::open_with_flags(
88 state.join(DATABASE),
89 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
90 )
91 .await
92 .map_err(|e| e.to_string())?;
93 let result = connection
94 .call(move |db| -> Result<_, Error> {
95 super::wait_when_busy(db)?;
96 let transaction = db.transaction()?;
97 if !schema_initialized(&transaction)? {
98 return Ok(Vec::new());
99 }
100 let runs = page(&transaction, &state, repos.as_deref(), limit, offset, None)?;
101 transaction.commit()?;
102 Ok(runs)
103 })
104 .await
105 .map_err(|e| e.to_string());
106 super::connection::finish(connection, result).await
107}
108
109pub(super) fn page(
112 db: &rusqlite::Connection,
113 state: &Path,
114 repos: Option<&str>,
115 limit: u32,
116 offset: u32,
117 pinned: Option<&crate::types::RunId>,
118) -> Result<Vec<RunSummary>, Error> {
119 let mut runs = listed(db, state, repos, limit, offset, pinned)?;
120 count(db, &mut runs)?;
121 Ok(runs)
122}
123
124fn listed(
125 db: &rusqlite::Connection,
126 state: &Path,
127 repos: Option<&str>,
128 limit: u32,
129 offset: u32,
130 pinned: Option<&crate::types::RunId>,
131) -> Result<Vec<RunSummary>, Error> {
132 let mut statement = db.prepare_cached(
133 "WITH page AS (
134 SELECT id,repo,json_extract(data,'$.createdAt') created,json_extract(data,'$.completedAt') completed,status,rowid serial,json_extract(data,'$.comment') comment
135 FROM runs WHERE (?1 IS NULL OR repo IN (SELECT value FROM json_each(?1)))
136 ORDER BY json_extract(data,'$.createdAt') DESC,rowid DESC LIMIT ?2 OFFSET ?3)
137 SELECT id,repo,created,completed,status,0,serial,comment FROM page UNION ALL
138 SELECT id,repo,json_extract(data,'$.createdAt'),json_extract(data,'$.completedAt'),status,1,rowid,json_extract(data,'$.comment') FROM runs
139 WHERE id=?4 AND id NOT IN (SELECT id FROM page)
140 AND (?1 IS NULL OR repo IN (SELECT value FROM json_each(?1))) ORDER BY 3 DESC,7 DESC",
141 )?;
142 let runs = statement
143 .query_map(params![repos, limit, offset, pinned], |row| {
144 Ok(RunSummary {
145 id: row.get(0)?,
146 repo_path: PathBuf::from(row.get::<_, String>(1)?),
147 created_at: row.get(2)?,
148 completed_at: row.get(3)?,
149 status: row.get(4)?,
150 comment: row
153 .get::<_, Option<String>>(7)?
154 .and_then(|comment| comment.parse().ok()),
155 counts: BTreeMap::new(),
156 unreadable: Vec::new(),
157 })
158 })?
159 .collect::<Result<Vec<_>, _>>()?;
160 crate::workspace::outside_workspaces(runs.iter().map(|run| run.repo_path.as_path()), state)
161 .map_err(|error| Error::Invalid(error.to_string()))?;
162 Ok(runs)
163}
164
165fn count(db: &rusqlite::Connection, runs: &mut [RunSummary]) -> Result<(), Error> {
167 let ids: Vec<_> = runs.iter().map(|run| run.id.clone()).collect();
168 let mut counts: BTreeMap<crate::types::RunId, BTreeMap<crate::types::RequestStatus, u64>> =
169 BTreeMap::new();
170 let mut statement = db.prepare_cached(
171 "SELECT run_id,status,count(*) FROM requests WHERE run_id IN (SELECT value FROM json_each(?1)) GROUP BY run_id,status",
172 )?;
173 let rows = statement.query_map([serde_json::to_string(&ids)?], |row| {
174 Ok((
175 row.get::<_, crate::types::RunId>(0)?,
176 row.get::<_, crate::types::RequestStatus>(1)?,
177 row.get::<_, i64>(2)?,
178 ))
179 })?;
180 for row in rows {
181 let (run, status, count) = row?;
182 let count = u64::try_from(count).expect("COUNT(*) is nonnegative");
183 counts.entry(run).or_default().insert(status, count);
184 }
185 for run in runs {
186 run.counts = counts.remove(&run.id).unwrap_or_default();
187 }
188 Ok(())
189}