1use super::*;
2
3impl Store {
4 pub fn upsert_guidance_candidate(&self, c: &crate::guidance::GuidanceCandidate) -> Result<()> {
5 let action_json = serde_json::to_string(&c.action)?;
6 let evidence_json = serde_json::to_string(&c.evidence)?;
7 self.conn.execute(
8 "INSERT OR REPLACE INTO guidance_candidates
9 (id, artifact_kind, artifact_id, action_json, status, rationale,
10 evidence_json, created_at_ms, applied_at_ms, treatment_fingerprint,
11 experiment_id, backup_path)
12 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
13 params![
14 c.id,
15 c.artifact.kind.as_str(),
16 c.artifact.slug,
17 action_json,
18 c.status.as_str(),
19 c.rationale,
20 evidence_json,
21 c.created_at_ms as i64,
22 c.applied_at_ms.map(|v| v as i64),
23 c.treatment_fingerprint.as_deref(),
24 c.experiment_id.as_deref(),
25 c.backup_path.as_deref(),
26 ],
27 )?;
28 Ok(())
29 }
30
31 pub fn list_guidance_candidates(&self) -> Result<Vec<crate::guidance::GuidanceCandidate>> {
32 let mut stmt = self.conn.prepare(GUIDANCE_CANDIDATE_SELECT)?;
33 let rows = stmt.query_map([], guidance_candidate_row)?;
34 rows.map(|r| r.map_err(anyhow::Error::from)).collect()
35 }
36
37 pub fn get_guidance_candidate(
38 &self,
39 id: &str,
40 ) -> Result<Option<crate::guidance::GuidanceCandidate>> {
41 self.conn
42 .query_row(
43 &format!("{GUIDANCE_CANDIDATE_SELECT} WHERE id = ?1"),
44 params![id],
45 guidance_candidate_row,
46 )
47 .optional()
48 .map_err(Into::into)
49 }
50
51 pub fn rejected_guidance_candidates(
52 &self,
53 artifact: &crate::guidance::ArtifactRef,
54 limit: usize,
55 ) -> Result<Vec<crate::guidance::GuidanceCandidate>> {
56 let sql = format!(
57 "{GUIDANCE_CANDIDATE_SELECT} WHERE artifact_kind = ?1 AND artifact_id = ?2
58 AND status = 'rejected' ORDER BY created_at_ms DESC LIMIT ?3"
59 );
60 let mut stmt = self.conn.prepare(&sql)?;
61 let rows = stmt.query_map(
62 params![artifact.kind.as_str(), artifact.slug.as_str(), limit as i64],
63 guidance_candidate_row,
64 )?;
65 rows.map(|r| r.map_err(anyhow::Error::from)).collect()
66 }
67
68 pub fn set_guidance_candidate_status(
69 &self,
70 id: &str,
71 status: crate::guidance::CandidateStatus,
72 ) -> Result<()> {
73 self.conn.execute(
74 "UPDATE guidance_candidates SET status = ?2 WHERE id = ?1",
75 params![id, status.as_str()],
76 )?;
77 Ok(())
78 }
79}
80
81const GUIDANCE_CANDIDATE_SELECT: &str = "SELECT id, artifact_kind, artifact_id, action_json,
82 status, rationale, evidence_json, created_at_ms, applied_at_ms,
83 treatment_fingerprint, experiment_id, backup_path FROM guidance_candidates";
84
85fn guidance_candidate_row(
86 r: &rusqlite::Row<'_>,
87) -> rusqlite::Result<crate::guidance::GuidanceCandidate> {
88 let kind: String = r.get(1)?;
89 let status: String = r.get(4)?;
90 Ok(crate::guidance::GuidanceCandidate {
91 id: r.get(0)?,
92 artifact: crate::guidance::ArtifactRef {
93 kind: crate::guidance::ArtifactKind::parse(&kind)
94 .unwrap_or(crate::guidance::ArtifactKind::Skill),
95 slug: r.get(2)?,
96 },
97 action: serde_json::from_str(&r.get::<_, String>(3)?)
98 .unwrap_or(crate::guidance::CandidateAction::ReviewOnly),
99 status: crate::guidance::CandidateStatus::parse(&status)
100 .unwrap_or(crate::guidance::CandidateStatus::Proposed),
101 rationale: r.get(5)?,
102 evidence: serde_json::from_str(&r.get::<_, String>(6)?).unwrap_or_default(),
103 created_at_ms: r.get::<_, i64>(7)? as u64,
104 applied_at_ms: r.get::<_, Option<i64>>(8)?.map(|v| v as u64),
105 treatment_fingerprint: r.get(9)?,
106 experiment_id: r.get(10)?,
107 backup_path: r.get(11)?,
108 })
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::guidance::{ArtifactKind, ArtifactRef, CandidateAction, CandidateStatus};
115
116 #[test]
117 fn candidate_round_trip_and_status() -> anyhow::Result<()> {
118 let dir = tempfile::tempdir()?;
119 let store = Store::open(&dir.path().join("k.db"))?;
120 let c = candidate();
121 store.upsert_guidance_candidate(&c)?;
122 store.set_guidance_candidate_status("c1", CandidateStatus::Rejected)?;
123 let got = store.get_guidance_candidate("c1")?.unwrap();
124 assert_eq!(got.status, CandidateStatus::Rejected);
125 Ok(())
126 }
127
128 #[test]
129 fn rejected_candidates_filter_by_artifact() -> anyhow::Result<()> {
130 let dir = tempfile::tempdir()?;
131 let store = Store::open(&dir.path().join("k.db"))?;
132 store.upsert_guidance_candidate(&candidate())?;
133 store.set_guidance_candidate_status("c1", CandidateStatus::Rejected)?;
134 let got = store.rejected_guidance_candidates(&candidate().artifact, 10)?;
135 assert_eq!(got.len(), 1);
136 Ok(())
137 }
138
139 fn candidate() -> crate::guidance::GuidanceCandidate {
140 crate::guidance::GuidanceCandidate {
141 id: "c1".into(),
142 artifact: ArtifactRef {
143 kind: ArtifactKind::Skill,
144 slug: "tdd".into(),
145 },
146 action: CandidateAction::ReviewOnly,
147 status: CandidateStatus::Proposed,
148 rationale: "inspect".into(),
149 evidence: vec!["e".into()],
150 created_at_ms: 1,
151 applied_at_ms: None,
152 treatment_fingerprint: None,
153 experiment_id: None,
154 backup_path: None,
155 }
156 }
157}