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