1use anyhow::{bail, Result};
2use rusqlite::types::Type;
3use rusqlite::{params, Connection, OptionalExtension, Row};
4use serde::{Deserialize, Serialize};
5
6use crate::git_util::{short_sha_for, GitCommitMetadata};
7
8mod poisoning;
9use poisoning::gate_link_summary;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct GitCommitRecord {
13 pub id: i64,
14 pub project: String,
15 pub repo_path: String,
16 pub sha: String,
17 pub short_sha: String,
18 pub branch: Option<String>,
19 pub message: Option<String>,
20 pub authored_at_epoch: Option<i64>,
21 pub changed_files: Vec<String>,
22 pub created_at_epoch: i64,
23 pub updated_at_epoch: i64,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct SessionSummaryTrace {
28 pub request: Option<String>,
29 pub completed: Option<String>,
30 pub decisions: Option<String>,
31 pub learned: Option<String>,
32 pub next_steps: Option<String>,
33 pub preferences: Option<String>,
34 pub created_at_epoch: Option<i64>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct CommitSessionLink {
39 pub session_id: String,
40 pub memory_session_id: Option<String>,
41 pub source: String,
42 pub linked_at_epoch: i64,
43 pub summary: Option<SessionSummaryTrace>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct CommitLookup {
48 pub git: GitCommitRecord,
49 pub sessions: Vec<CommitSessionLink>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct SessionCommit {
54 pub git: GitCommitRecord,
55 pub link: CommitSessionLink,
56}
57
58pub struct CommitMetadataInput<'a> {
59 pub project: &'a str,
60 pub repo_path: Option<&'a str>,
61 pub sha: &'a str,
62 pub short_sha: Option<&'a str>,
63 pub branch: Option<&'a str>,
64 pub message: Option<&'a str>,
65 pub authored_at_epoch: Option<i64>,
66 pub changed_files: &'a [String],
67}
68
69pub struct CommitLinkInput<'a> {
70 pub metadata: CommitMetadataInput<'a>,
71 pub session_id: &'a str,
72 pub memory_session_id: Option<&'a str>,
73 pub source: &'a str,
74}
75
76pub fn upsert_commit_metadata(conn: &Connection, input: &CommitMetadataInput<'_>) -> Result<i64> {
77 let sha = normalize_sha(input.sha)?;
78 let short_sha = input
79 .short_sha
80 .map(str::trim)
81 .filter(|value| !value.is_empty())
82 .map(str::to_string)
83 .unwrap_or_else(|| short_sha_for(&sha));
84 let repo_path = input
85 .repo_path
86 .map(str::trim)
87 .filter(|value| !value.is_empty())
88 .unwrap_or(input.project);
89 let changed_files = serde_json::to_string(input.changed_files)?;
90 let now = chrono::Utc::now().timestamp();
91
92 if sha != short_sha {
93 if let Some(id) =
94 find_upgradeable_placeholder_commit_id(conn, input.project, &sha, &short_sha)?
95 {
96 conn.execute(
97 "UPDATE git_commits SET
98 repo_path = ?1,
99 sha = ?2,
100 short_sha = ?3,
101 branch = COALESCE(?4, branch),
102 message = COALESCE(?5, message),
103 authored_at_epoch = COALESCE(?6, authored_at_epoch),
104 changed_files = CASE WHEN ?7 != '[]' THEN ?7 ELSE changed_files END,
105 updated_at_epoch = ?8
106 WHERE id = ?9",
107 params![
108 repo_path,
109 sha,
110 short_sha,
111 input.branch,
112 input.message,
113 input.authored_at_epoch,
114 changed_files,
115 now,
116 id
117 ],
118 )?;
119 return Ok(id);
120 }
121 }
122
123 conn.execute(
124 "INSERT INTO git_commits
125 (project, repo_path, sha, short_sha, branch, message, authored_at_epoch,
126 changed_files, created_at_epoch, updated_at_epoch)
127 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
128 ON CONFLICT(project, sha) DO UPDATE SET
129 repo_path = excluded.repo_path,
130 short_sha = excluded.short_sha,
131 branch = COALESCE(excluded.branch, git_commits.branch),
132 message = COALESCE(excluded.message, git_commits.message),
133 authored_at_epoch = COALESCE(excluded.authored_at_epoch, git_commits.authored_at_epoch),
134 changed_files = CASE
135 WHEN excluded.changed_files != '[]' THEN excluded.changed_files
136 ELSE git_commits.changed_files
137 END,
138 updated_at_epoch = excluded.updated_at_epoch",
139 params![
140 input.project,
141 repo_path,
142 sha,
143 short_sha,
144 input.branch,
145 input.message,
146 input.authored_at_epoch,
147 changed_files,
148 now
149 ],
150 )?;
151
152 let id = conn.query_row(
153 "SELECT id FROM git_commits WHERE project = ?1 AND sha = ?2",
154 params![input.project, sha],
155 |row| row.get(0),
156 )?;
157 Ok(id)
158}
159
160pub fn link_commit_to_session(conn: &Connection, input: &CommitLinkInput<'_>) -> Result<i64> {
161 let session_id = input.session_id.trim();
162 if session_id.is_empty() {
163 bail!("session_id is required to link a commit");
164 }
165 let source = input.source.trim();
166 if source.is_empty() {
167 bail!("source is required to link a commit");
168 }
169
170 let commit_id = upsert_commit_metadata(conn, &input.metadata)?;
171 link_session_to_commit_id(
172 conn,
173 commit_id,
174 None,
175 session_id,
176 input.memory_session_id,
177 source,
178 )?;
179 Ok(commit_id)
180}
181
182pub fn link_git_metadata_to_session(
183 conn: &Connection,
184 project: &str,
185 session_id: &str,
186 memory_session_id: &str,
187 metadata: &GitCommitMetadata,
188 source: &str,
189) -> Result<i64> {
190 link_commit_to_session(
191 conn,
192 &CommitLinkInput {
193 metadata: CommitMetadataInput {
194 project,
195 repo_path: Some(&metadata.repo_path),
196 sha: &metadata.sha,
197 short_sha: Some(&metadata.short_sha),
198 branch: metadata.branch.as_deref(),
199 message: metadata.message.as_deref(),
200 authored_at_epoch: metadata.authored_at_epoch,
201 changed_files: &metadata.changed_files,
202 },
203 session_id,
204 memory_session_id: Some(memory_session_id),
205 source,
206 },
207 )
208}
209
210pub fn link_captured_git_metadata_to_session(
211 conn: &Connection,
212 project: &str,
213 session_row_id: i64,
214 session_id: &str,
215 memory_session_id: &str,
216 metadata: &GitCommitMetadata,
217) -> Result<i64> {
218 validate_capture_session_identity(conn, project, session_row_id, session_id)?;
219 let full_sha = validate_full_commit_sha(&metadata.sha)?;
220 let short_sha = metadata.short_sha.trim().to_ascii_lowercase();
221 if short_sha.len() < 7
222 || !short_sha.bytes().all(|byte| byte.is_ascii_hexdigit())
223 || !full_sha.starts_with(&short_sha)
224 {
225 bail!("captured Git metadata short SHA does not match full SHA {full_sha}");
226 }
227 let commit_id = upsert_commit_metadata(
228 conn,
229 &CommitMetadataInput {
230 project,
231 repo_path: Some(&metadata.repo_path),
232 sha: &full_sha,
233 short_sha: Some(&short_sha),
234 branch: metadata.branch.as_deref(),
235 message: metadata.message.as_deref(),
236 authored_at_epoch: metadata.authored_at_epoch,
237 changed_files: &metadata.changed_files,
238 },
239 )?;
240 link_session_to_commit_id(
241 conn,
242 commit_id,
243 Some(session_row_id),
244 session_id,
245 Some(memory_session_id),
246 "capture_git_evidence",
247 )?;
248 Ok(commit_id)
249}
250
251pub fn link_observed_commit_to_session(
252 conn: &Connection,
253 project: &str,
254 session_id: &str,
255 memory_session_id: &str,
256 commit_sha: &str,
257 branch: Option<&str>,
258 metadata: Option<&GitCommitMetadata>,
259) -> Result<i64> {
260 if let Some(metadata) = metadata.filter(|metadata| metadata.matches_sha(commit_sha)) {
261 return link_git_metadata_to_session(
262 conn,
263 project,
264 session_id,
265 memory_session_id,
266 metadata,
267 "git_metadata",
268 );
269 }
270
271 if let Some(commit_id) = find_existing_commit_id(conn, project, commit_sha)? {
272 link_session_to_commit_id(
273 conn,
274 commit_id,
275 None,
276 session_id,
277 Some(memory_session_id),
278 "observations",
279 )?;
280 return Ok(commit_id);
281 }
282
283 let changed_files = Vec::new();
284 link_commit_to_session(
285 conn,
286 &CommitLinkInput {
287 metadata: CommitMetadataInput {
288 project,
289 repo_path: Some(project),
290 sha: commit_sha,
291 short_sha: None,
292 branch,
293 message: None,
294 authored_at_epoch: None,
295 changed_files: &changed_files,
296 },
297 session_id,
298 memory_session_id: Some(memory_session_id),
299 source: "observations",
300 },
301 )
302}
303
304pub fn link_observed_commits_for_session(
305 conn: &Connection,
306 project: &str,
307 session_id: &str,
308 memory_session_id: &str,
309) -> Result<usize> {
310 let mut stmt = conn.prepare(
311 "SELECT commit_sha, branch
312 FROM observations
313 WHERE project = ?1
314 AND memory_session_id = ?2
315 AND commit_sha IS NOT NULL
316 AND length(trim(commit_sha)) > 0
317 GROUP BY commit_sha, branch",
318 )?;
319 let rows = stmt.query_map(params![project, memory_session_id], |row| {
320 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
321 })?;
322 let observed = rows.collect::<rusqlite::Result<Vec<_>>>()?;
323
324 for (commit_sha, branch) in &observed {
325 link_observed_commit_to_session(
326 conn,
327 project,
328 session_id,
329 memory_session_id,
330 commit_sha,
331 branch.as_deref(),
332 None,
333 )?;
334 }
335
336 Ok(observed.len())
337}
338
339fn find_upgradeable_placeholder_commit_id(
340 conn: &Connection,
341 project: &str,
342 full_sha: &str,
343 short_sha: &str,
344) -> Result<Option<i64>> {
345 let mut stmt = conn.prepare(
346 "SELECT id
347 FROM git_commits
348 WHERE project = ?1
349 AND sha != ?2
350 AND (
351 sha = ?3
352 OR short_sha = ?3
353 OR ?2 LIKE sha || '%'
354 OR ?2 LIKE short_sha || '%'
355 )
356 ORDER BY
357 CASE
358 WHEN sha = ?3 THEN 0
359 WHEN short_sha = ?3 THEN 1
360 WHEN ?2 LIKE sha || '%' THEN 2
361 ELSE 3
362 END,
363 length(sha) DESC,
364 updated_at_epoch DESC
365 LIMIT 2",
366 )?;
367 let ids = stmt
368 .query_map(params![project, full_sha, short_sha], |row| {
369 row.get::<_, i64>(0)
370 })?
371 .collect::<rusqlite::Result<Vec<_>>>()?;
372 match ids.as_slice() {
373 [] => Ok(None),
374 [id] => Ok(Some(*id)),
375 _ => bail!("ambiguous commit SHA placeholder: {full_sha}"),
376 }
377}
378
379fn find_existing_commit_id(
380 conn: &Connection,
381 project: &str,
382 sha_or_prefix: &str,
383) -> Result<Option<i64>> {
384 let needle = normalize_sha(sha_or_prefix)?;
385 let prefix = format!("{needle}%");
386 let mut stmt = conn.prepare(
387 "SELECT id
388 FROM git_commits
389 WHERE project = ?1
390 AND (
391 sha = ?2
392 OR short_sha = ?2
393 OR sha LIKE ?3
394 OR short_sha LIKE ?3
395 OR ?2 LIKE sha || '%'
396 OR ?2 LIKE short_sha || '%'
397 )
398 ORDER BY CASE WHEN sha = ?2 THEN 0 WHEN short_sha = ?2 THEN 1 ELSE 2 END,
399 updated_at_epoch DESC
400 LIMIT 2",
401 )?;
402 let ids = stmt
403 .query_map(params![project, needle, prefix], |row| row.get::<_, i64>(0))?
404 .collect::<rusqlite::Result<Vec<_>>>()?;
405 match ids.as_slice() {
406 [] => Ok(None),
407 [id] => Ok(Some(*id)),
408 _ => bail!("ambiguous commit SHA prefix: {sha_or_prefix}"),
409 }
410}
411
412fn link_session_to_commit_id(
413 conn: &Connection,
414 commit_id: i64,
415 session_row_id: Option<i64>,
416 session_id: &str,
417 memory_session_id: Option<&str>,
418 source: &str,
419) -> Result<()> {
420 let now = chrono::Utc::now().timestamp();
421 if let Some(session_row_id) = session_row_id {
422 promote_exact_legacy_link(
423 conn,
424 commit_id,
425 session_row_id,
426 session_id,
427 memory_session_id,
428 )?;
429 conn.execute(
430 "INSERT INTO git_commit_sessions
431 (commit_id, session_row_id, session_id, memory_session_id, source, linked_at_epoch)
432 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
433 ON CONFLICT(commit_id, session_row_id) WHERE session_row_id IS NOT NULL DO UPDATE SET
434 session_id = excluded.session_id,
435 memory_session_id = COALESCE(excluded.memory_session_id, git_commit_sessions.memory_session_id),
436 source = CASE
437 WHEN git_commit_sessions.source IN ('git_metadata', 'capture_git_evidence')
438 THEN git_commit_sessions.source
439 WHEN excluded.source IN ('git_metadata', 'capture_git_evidence')
440 THEN excluded.source
441 ELSE excluded.source
442 END",
443 params![
444 commit_id,
445 session_row_id,
446 session_id,
447 memory_session_id,
448 source,
449 now
450 ],
451 )?;
452 } else {
453 conn.execute(
454 "INSERT INTO git_commit_sessions
455 (commit_id, session_row_id, session_id, memory_session_id, source, linked_at_epoch)
456 VALUES (?1, NULL, ?2, ?3, ?4, ?5)
457 ON CONFLICT(commit_id, session_id) WHERE session_row_id IS NULL DO UPDATE SET
458 memory_session_id = COALESCE(excluded.memory_session_id, git_commit_sessions.memory_session_id),
459 source = CASE
460 WHEN git_commit_sessions.source IN ('git_metadata', 'capture_git_evidence')
461 THEN git_commit_sessions.source
462 WHEN excluded.source IN ('git_metadata', 'capture_git_evidence')
463 THEN excluded.source
464 ELSE excluded.source
465 END",
466 params![commit_id, session_id, memory_session_id, source, now],
467 )?;
468 }
469 Ok(())
470}
471
472fn promote_exact_legacy_link(
473 conn: &Connection,
474 commit_id: i64,
475 session_row_id: i64,
476 session_id: &str,
477 memory_session_id: Option<&str>,
478) -> Result<()> {
479 let expected_memory_session_id = format!("capture-rollup-{session_row_id}");
480 if memory_session_id != Some(expected_memory_session_id.as_str()) {
481 return Ok(());
482 }
483 conn.execute(
484 "DELETE FROM git_commit_sessions
485 WHERE commit_id = ?1
486 AND session_row_id IS NULL
487 AND session_id = ?2
488 AND memory_session_id = ?3
489 AND EXISTS (
490 SELECT 1 FROM git_commit_sessions durable
491 WHERE durable.commit_id = ?1 AND durable.session_row_id = ?4
492 )",
493 params![
494 commit_id,
495 session_id,
496 expected_memory_session_id,
497 session_row_id
498 ],
499 )?;
500 conn.execute(
501 "UPDATE git_commit_sessions
502 SET session_row_id = ?4
503 WHERE commit_id = ?1
504 AND session_row_id IS NULL
505 AND session_id = ?2
506 AND memory_session_id = ?3",
507 params![
508 commit_id,
509 session_id,
510 expected_memory_session_id,
511 session_row_id
512 ],
513 )?;
514 Ok(())
515}
516
517pub fn lookup_commit(
518 conn: &Connection,
519 project: Option<&str>,
520 sha_or_prefix: &str,
521) -> Result<Vec<CommitLookup>> {
522 let needle = normalize_sha(sha_or_prefix)?;
523 let prefix = format!("{needle}%");
524 let commits = if let Some(project) = project {
525 let mut stmt = conn.prepare(
526 "SELECT id, project, repo_path, sha, short_sha, branch, message,
527 authored_at_epoch, changed_files, created_at_epoch, updated_at_epoch
528 FROM git_commits
529 WHERE project = ?1
530 AND (sha = ?2 OR short_sha = ?2 OR sha LIKE ?3 OR short_sha LIKE ?3)
531 ORDER BY CASE WHEN sha = ?2 THEN 0 WHEN short_sha = ?2 THEN 1 ELSE 2 END,
532 updated_at_epoch DESC
533 LIMIT 20",
534 )?;
535 let rows = stmt.query_map(params![project, needle, prefix], commit_from_row)?;
536 rows.collect::<rusqlite::Result<Vec<_>>>()?
537 } else {
538 let mut stmt = conn.prepare(
539 "SELECT id, project, repo_path, sha, short_sha, branch, message,
540 authored_at_epoch, changed_files, created_at_epoch, updated_at_epoch
541 FROM git_commits
542 WHERE sha = ?1 OR short_sha = ?1 OR sha LIKE ?2 OR short_sha LIKE ?2
543 ORDER BY CASE WHEN sha = ?1 THEN 0 WHEN short_sha = ?1 THEN 1 ELSE 2 END,
544 updated_at_epoch DESC
545 LIMIT 20",
546 )?;
547 let rows = stmt.query_map(params![needle, prefix], commit_from_row)?;
548 rows.collect::<rusqlite::Result<Vec<_>>>()?
549 };
550
551 commits
552 .into_iter()
553 .map(|git| {
554 let sessions = linked_sessions_for_commit(conn, git.id, &git.project)?;
555 Ok(CommitLookup { git, sessions })
556 })
557 .collect()
558}
559
560pub fn commits_for_session(
561 conn: &Connection,
562 project: Option<&str>,
563 session_id: &str,
564 limit: i64,
565) -> Result<Vec<SessionCommit>> {
566 let session_id = session_id.trim();
567 if session_id.is_empty() {
568 bail!("session_id is required");
569 }
570 let limit = limit.clamp(1, 100);
571 if let Some(project) = project {
572 query_session_commits(
573 conn,
574 "WHERE c.project = ?1 AND (l.session_id = ?2 OR l.memory_session_id = ?2)",
575 params![project, session_id, limit],
576 )
577 } else {
578 query_session_commits(
579 conn,
580 "WHERE l.session_id = ?1 OR l.memory_session_id = ?1",
581 params![session_id, limit],
582 )
583 }
584}
585
586fn query_session_commits<P>(
587 conn: &Connection,
588 where_clause: &str,
589 params: P,
590) -> Result<Vec<SessionCommit>>
591where
592 P: rusqlite::Params,
593{
594 let sql = format!(
595 "SELECT c.id, c.project, c.repo_path, c.sha, c.short_sha, c.branch, c.message,
596 c.authored_at_epoch, c.changed_files, c.created_at_epoch, c.updated_at_epoch,
597 l.session_id, l.memory_session_id, l.source, l.linked_at_epoch,
598 ss.request, ss.completed, ss.decisions, ss.learned, ss.next_steps,
599 ss.preferences, ss.created_at_epoch, ss.id
600 FROM git_commit_sessions l
601 JOIN git_commits c ON c.id = l.commit_id
602 LEFT JOIN session_summaries ss ON ss.id = (
603 SELECT latest.id
604 FROM session_summaries latest
605 WHERE latest.memory_session_id = l.memory_session_id
606 AND latest.project = c.project
607 AND COALESCE(latest.poisoning_status, 'legacy_unscanned') != 'quarantined'
608 ORDER BY COALESCE(latest.covered_to_event_id, 0) DESC,
609 latest.created_at_epoch DESC,
610 latest.id DESC
611 LIMIT 1
612 )
613 {where_clause}
614 ORDER BY COALESCE(c.authored_at_epoch, c.updated_at_epoch) DESC, c.id DESC
615 LIMIT ?{}",
616 if where_clause.contains("?2") {
617 "3"
618 } else {
619 "2"
620 }
621 );
622 let mut stmt = conn.prepare(&sql)?;
623 let rows = stmt.query_map(params, |row| {
624 let (link, summary_id) = link_from_row(row, 11)?;
625 Ok((
626 SessionCommit {
627 git: commit_from_row(row)?,
628 link,
629 },
630 summary_id,
631 ))
632 })?;
633 let mut commits = Vec::new();
634 for row in rows {
635 let (mut commit, summary_id) = row?;
636 gate_link_summary(conn, &mut commit.link, summary_id);
637 commits.push(commit);
638 }
639 Ok(commits)
640}
641
642fn linked_sessions_for_commit(
643 conn: &Connection,
644 commit_id: i64,
645 project: &str,
646) -> Result<Vec<CommitSessionLink>> {
647 let mut stmt = conn.prepare(
648 "SELECT l.session_id, l.memory_session_id, l.source, l.linked_at_epoch,
649 ss.request, ss.completed, ss.decisions, ss.learned, ss.next_steps,
650 ss.preferences, ss.created_at_epoch, ss.id
651 FROM git_commit_sessions l
652 LEFT JOIN session_summaries ss ON ss.id = (
653 SELECT latest.id
654 FROM session_summaries latest
655 WHERE latest.memory_session_id = l.memory_session_id
656 AND latest.project = ?2
657 AND COALESCE(latest.poisoning_status, 'legacy_unscanned') != 'quarantined'
658 ORDER BY COALESCE(latest.covered_to_event_id, 0) DESC,
659 latest.created_at_epoch DESC,
660 latest.id DESC
661 LIMIT 1
662 )
663 WHERE l.commit_id = ?1
664 ORDER BY l.linked_at_epoch DESC",
665 )?;
666 let rows = stmt.query_map(params![commit_id, project], |row| link_from_row(row, 0))?;
667 let mut links = Vec::new();
668 for row in rows {
669 let (mut link, summary_id) = row?;
670 gate_link_summary(conn, &mut link, summary_id);
671 links.push(link);
672 }
673 Ok(links)
674}
675
676fn normalize_sha(raw: &str) -> Result<String> {
677 let sha = raw.trim();
678 if sha.is_empty() {
679 bail!("commit SHA is required");
680 }
681 Ok(sha.to_string())
682}
683
684fn validate_full_commit_sha(raw: &str) -> Result<String> {
685 let sha = raw.trim().to_ascii_lowercase();
686 if !matches!(sha.len(), 40 | 64) || !sha.bytes().all(|byte| byte.is_ascii_hexdigit()) {
687 bail!("captured commit SHA must be a full 40- or 64-character hexadecimal hash");
688 }
689 Ok(sha)
690}
691
692fn validate_capture_session_identity(
693 conn: &Connection,
694 project: &str,
695 session_row_id: i64,
696 session_id: &str,
697) -> Result<()> {
698 let identity = conn
699 .query_row(
700 "SELECT sessions.session_id, projects.project_path
701 FROM sessions
702 JOIN projects ON projects.id = sessions.project_id
703 WHERE sessions.id = ?1",
704 [session_row_id],
705 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
706 )
707 .optional()?;
708 let Some((stored_session_id, stored_project)) = identity else {
709 bail!("captured commit session_row_id {session_row_id} does not exist");
710 };
711 if stored_session_id != session_id || stored_project != project {
712 bail!(
713 "captured commit identity mismatch for session_row_id {session_row_id}: expected project={project} session={session_id}, stored project={stored_project} session={stored_session_id}"
714 );
715 }
716 Ok(())
717}
718
719fn commit_from_row(row: &Row<'_>) -> rusqlite::Result<GitCommitRecord> {
720 Ok(GitCommitRecord {
721 id: row.get(0)?,
722 project: row.get(1)?,
723 repo_path: row.get(2)?,
724 sha: row.get(3)?,
725 short_sha: row.get(4)?,
726 branch: row.get(5)?,
727 message: row.get(6)?,
728 authored_at_epoch: row.get(7)?,
729 changed_files: changed_files_from_row(row, 8)?,
730 created_at_epoch: row.get(9)?,
731 updated_at_epoch: row.get(10)?,
732 })
733}
734
735fn link_from_row(
736 row: &Row<'_>,
737 offset: usize,
738) -> rusqlite::Result<(CommitSessionLink, Option<i64>)> {
739 let summary = summary_from_row(row, offset + 4)?;
740 let summary_id: Option<i64> = row.get(offset + 11)?;
741 Ok((
742 CommitSessionLink {
743 session_id: row.get(offset)?,
744 memory_session_id: row.get(offset + 1)?,
745 source: row.get(offset + 2)?,
746 linked_at_epoch: row.get(offset + 3)?,
747 summary,
748 },
749 summary_id,
750 ))
751}
752
753fn summary_from_row(row: &Row<'_>, offset: usize) -> rusqlite::Result<Option<SessionSummaryTrace>> {
754 let summary = SessionSummaryTrace {
755 request: row.get(offset)?,
756 completed: row.get(offset + 1)?,
757 decisions: row.get(offset + 2)?,
758 learned: row.get(offset + 3)?,
759 next_steps: row.get(offset + 4)?,
760 preferences: row.get(offset + 5)?,
761 created_at_epoch: row.get(offset + 6)?,
762 };
763 if summary.request.is_none()
764 && summary.completed.is_none()
765 && summary.decisions.is_none()
766 && summary.learned.is_none()
767 && summary.next_steps.is_none()
768 && summary.preferences.is_none()
769 && summary.created_at_epoch.is_none()
770 {
771 Ok(None)
772 } else {
773 Ok(Some(summary))
774 }
775}
776
777fn changed_files_from_row(row: &Row<'_>, idx: usize) -> rusqlite::Result<Vec<String>> {
778 let raw: String = row.get(idx)?;
779 serde_json::from_str(&raw)
780 .map_err(|err| rusqlite::Error::FromSqlConversionFailure(idx, Type::Text, Box::new(err)))
781}
782
783#[cfg(test)]
784mod tests;