1use sha2::{Digest, Sha256};
18
19pub const TITLE_MAX_CHARS: usize = 120;
23
24pub const BODY_MAX_CHARS: usize = 2000;
28
29pub const MAX_FILE_PATTERNS: usize = 3;
33
34pub const ORIGIN: &str = "session_mined";
36
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47pub struct SessionMinedCandidate {
48 pub session_id: String,
52 pub ts_ms: i64,
54 pub source_repo: String,
59 pub title: String,
64 pub body: String,
67 pub file_patterns: Vec<String>,
71 pub gate_model: String,
74 pub gate_verdict: String,
79 pub content_hash: String,
83 pub origin: String,
85 pub requires_human_approval: bool,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
93pub enum CandidateError {
94 #[error("session-mined candidate is missing source_repo — drop")]
97 MissingSourceRepo,
98 #[error("session-mined candidate is missing session_id — drop")]
101 MissingSessionId,
102 #[error("session-mined candidate title invalid (empty or > {TITLE_MAX_CHARS} chars)")]
104 InvalidTitle,
105 #[error("session-mined candidate body invalid (empty or > {BODY_MAX_CHARS} chars)")]
107 InvalidBody,
108 #[error("session-mined candidate must carry 1-{MAX_FILE_PATTERNS} file patterns")]
110 InvalidFilePatterns,
111 #[error("session-mined candidate is missing gate_model")]
113 MissingGateModel,
114 #[error("session-mined candidate gate_verdict must be 'KEEP' or 'MERGE:<id>'")]
116 InvalidGateVerdict,
117 #[error("session-mined candidates must keep requires_human_approval = true")]
119 NotDraft,
120 #[error("session-mined candidate has wrong origin (expected {ORIGIN})")]
122 WrongOrigin,
123}
124
125impl SessionMinedCandidate {
126 pub fn try_new(args: SessionMinedCandidateArgs) -> Result<Self, CandidateError> {
130 let SessionMinedCandidateArgs {
131 session_id,
132 ts_ms,
133 source_repo,
134 title,
135 body,
136 file_patterns,
137 gate_model,
138 gate_verdict,
139 } = args;
140
141 let session_id = session_id.trim().to_owned();
142 if session_id.is_empty() {
143 return Err(CandidateError::MissingSessionId);
144 }
145 let source_repo = source_repo.trim().to_owned();
146 if source_repo.is_empty() {
147 return Err(CandidateError::MissingSourceRepo);
148 }
149 let title = truncate_chars(title.trim(), TITLE_MAX_CHARS);
150 if title.is_empty() {
151 return Err(CandidateError::InvalidTitle);
152 }
153 let body = truncate_chars(body.trim(), BODY_MAX_CHARS);
154 if body.is_empty() {
155 return Err(CandidateError::InvalidBody);
156 }
157 let file_patterns: Vec<String> = file_patterns
158 .into_iter()
159 .map(|p| p.trim().to_owned())
160 .filter(|p| !p.is_empty())
161 .take(MAX_FILE_PATTERNS)
162 .collect();
163 if file_patterns.is_empty() {
164 return Err(CandidateError::InvalidFilePatterns);
165 }
166 let gate_model = gate_model.trim().to_owned();
167 if gate_model.is_empty() {
168 return Err(CandidateError::MissingGateModel);
169 }
170 let gate_verdict = gate_verdict.trim().to_owned();
171 if !is_valid_verdict(&gate_verdict) {
172 return Err(CandidateError::InvalidGateVerdict);
173 }
174
175 let content_hash = compute_content_hash(&source_repo, &title, &body);
176
177 Ok(Self {
178 session_id,
179 ts_ms,
180 source_repo,
181 title,
182 body,
183 file_patterns,
184 gate_model,
185 gate_verdict,
186 content_hash,
187 origin: ORIGIN.to_owned(),
188 requires_human_approval: true,
189 })
190 }
191
192 pub fn validate(&self) -> Result<(), CandidateError> {
197 if self.session_id.trim().is_empty() {
198 return Err(CandidateError::MissingSessionId);
199 }
200 if self.source_repo.trim().is_empty() {
201 return Err(CandidateError::MissingSourceRepo);
202 }
203 if self.title.is_empty() || self.title.chars().count() > TITLE_MAX_CHARS {
204 return Err(CandidateError::InvalidTitle);
205 }
206 if self.body.is_empty() || self.body.chars().count() > BODY_MAX_CHARS {
207 return Err(CandidateError::InvalidBody);
208 }
209 if self.file_patterns.is_empty() || self.file_patterns.len() > MAX_FILE_PATTERNS {
210 return Err(CandidateError::InvalidFilePatterns);
211 }
212 if self.gate_model.trim().is_empty() {
213 return Err(CandidateError::MissingGateModel);
214 }
215 if !is_valid_verdict(&self.gate_verdict) {
216 return Err(CandidateError::InvalidGateVerdict);
217 }
218 if self.origin != ORIGIN {
219 return Err(CandidateError::WrongOrigin);
220 }
221 if !self.requires_human_approval {
222 return Err(CandidateError::NotDraft);
223 }
224 Ok(())
225 }
226}
227
228#[derive(Debug, Clone)]
232pub struct SessionMinedCandidateArgs {
233 pub session_id: String,
234 pub ts_ms: i64,
235 pub source_repo: String,
236 pub title: String,
237 pub body: String,
238 pub file_patterns: Vec<String>,
239 pub gate_model: String,
240 pub gate_verdict: String,
241}
242
243fn is_valid_verdict(verdict: &str) -> bool {
244 if verdict == "KEEP" {
245 return true;
246 }
247 if let Some(rest) = verdict.strip_prefix("MERGE:") {
248 return !rest.trim().is_empty();
249 }
250 false
251}
252
253pub fn compute_content_hash(source_repo: &str, title: &str, body: &str) -> String {
258 let mut hasher = Sha256::new();
259 hasher.update(source_repo.as_bytes());
260 hasher.update(b"|");
261 hasher.update(title.as_bytes());
262 hasher.update(b"|");
263 hasher.update(body.as_bytes());
264 let digest = hasher.finalize();
265 let mut hex = String::with_capacity(16);
266 for byte in digest.iter().take(8) {
267 hex.push_str(&format!("{byte:02x}"));
268 }
269 hex
270}
271
272fn truncate_chars(s: &str, max_chars: usize) -> String {
273 if s.chars().count() <= max_chars {
274 return s.to_owned();
275 }
276 let mut out: String = s.chars().take(max_chars.saturating_sub(1)).collect();
277 out.push('…');
278 out
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn args() -> SessionMinedCandidateArgs {
286 SessionMinedCandidateArgs {
287 session_id: "sess_test".to_owned(),
288 ts_ms: 1_714_000_000_000,
289 source_repo: "owner/repo".to_owned(),
290 title: "Prefer typed deserialization over Value::as_str".to_owned(),
291 body: "When parsing oRPC payloads, deserialize into a concrete struct \
292 instead of walking serde_json::Value with as_str()."
293 .to_owned(),
294 file_patterns: vec!["src/**/*.rs".to_owned()],
295 gate_model: "claude:haiku".to_owned(),
296 gate_verdict: "KEEP".to_owned(),
297 }
298 }
299
300 #[test]
301 fn try_new_sets_origin_and_draft_flag_unconditionally() {
302 let cand = SessionMinedCandidate::try_new(args()).expect("valid");
303 assert_eq!(cand.origin, ORIGIN);
304 assert!(
305 cand.requires_human_approval,
306 "session-mined candidates must default to draft"
307 );
308 }
309
310 #[test]
311 fn try_new_rejects_missing_source_repo() {
312 let mut a = args();
314 a.source_repo = String::new();
315 let err = SessionMinedCandidate::try_new(a).unwrap_err();
316 assert_eq!(err, CandidateError::MissingSourceRepo);
317 }
318
319 #[test]
320 fn try_new_rejects_missing_session_id() {
321 let mut a = args();
322 a.session_id = " ".to_owned();
323 let err = SessionMinedCandidate::try_new(a).unwrap_err();
324 assert_eq!(err, CandidateError::MissingSessionId);
325 }
326
327 #[test]
328 fn try_new_rejects_empty_file_patterns() {
329 let mut a = args();
330 a.file_patterns = vec![];
331 let err = SessionMinedCandidate::try_new(a).unwrap_err();
332 assert_eq!(err, CandidateError::InvalidFilePatterns);
333
334 let mut a = args();
335 a.file_patterns = vec![" ".to_owned(), String::new()];
336 let err = SessionMinedCandidate::try_new(a).unwrap_err();
337 assert_eq!(err, CandidateError::InvalidFilePatterns);
338 }
339
340 #[test]
341 fn try_new_caps_file_patterns_at_three() {
342 let mut a = args();
343 a.file_patterns = vec![
344 "a.rs".to_owned(),
345 "b.rs".to_owned(),
346 "c.rs".to_owned(),
347 "d.rs".to_owned(),
348 ];
349 let cand = SessionMinedCandidate::try_new(a).expect("valid");
350 assert_eq!(cand.file_patterns.len(), MAX_FILE_PATTERNS);
351 }
352
353 #[test]
354 fn try_new_validates_verdict_shape() {
355 for bad in ["", "MERGE:", "merge:abc", "REJECT", "merge"] {
356 let mut a = args();
357 a.gate_verdict = bad.to_owned();
358 let err = SessionMinedCandidate::try_new(a).unwrap_err();
359 assert_eq!(err, CandidateError::InvalidGateVerdict, "verdict='{bad}'");
360 }
361 for ok in ["KEEP", "MERGE:rule-123"] {
362 let mut a = args();
363 a.gate_verdict = ok.to_owned();
364 SessionMinedCandidate::try_new(a).expect("verdict must be accepted");
365 }
366 }
367
368 #[test]
369 fn try_new_truncates_oversize_title_and_body() {
370 let long: String = "x".repeat(TITLE_MAX_CHARS + 50);
371 let big: String = "y".repeat(BODY_MAX_CHARS + 100);
372 let mut a = args();
373 a.title.clone_from(&long);
374 a.body.clone_from(&big);
375 let cand = SessionMinedCandidate::try_new(a).expect("valid");
376 assert!(cand.title.chars().count() <= TITLE_MAX_CHARS);
377 assert!(cand.body.chars().count() <= BODY_MAX_CHARS);
378 }
379
380 #[test]
381 fn content_hash_is_stable_and_input_sensitive() {
382 let a = SessionMinedCandidate::try_new(args()).unwrap();
383 let b = SessionMinedCandidate::try_new(args()).unwrap();
384 assert_eq!(a.content_hash, b.content_hash);
385 assert_eq!(a.content_hash.len(), 16);
386
387 let mut other = args();
388 other.title = "Different rule".to_owned();
389 let c = SessionMinedCandidate::try_new(other).unwrap();
390 assert_ne!(a.content_hash, c.content_hash);
391 }
392
393 #[test]
394 fn validate_rejects_tampered_origin_or_unpublished_off() {
395 let cand = SessionMinedCandidate::try_new(args()).unwrap();
396 cand.validate().unwrap();
397
398 let mut tampered = cand.clone();
399 tampered.origin = "remember_rule".to_owned();
400 assert_eq!(
401 tampered.validate().unwrap_err(),
402 CandidateError::WrongOrigin
403 );
404
405 let mut leaked = cand;
406 leaked.requires_human_approval = false;
407 assert_eq!(leaked.validate().unwrap_err(), CandidateError::NotDraft);
408 }
409
410 #[test]
411 fn wire_shape_serializes_with_snake_case_keys() {
412 let cand = SessionMinedCandidate::try_new(args()).unwrap();
414 let value = serde_json::to_value(&cand).expect("serialize");
415 for required in [
416 "session_id",
417 "ts_ms",
418 "source_repo",
419 "title",
420 "body",
421 "file_patterns",
422 "gate_model",
423 "gate_verdict",
424 "content_hash",
425 "origin",
426 "requires_human_approval",
427 ] {
428 assert!(value.get(required).is_some(), "missing field: {required}");
429 }
430 assert_eq!(value["requires_human_approval"], true);
431 assert_eq!(value["origin"], ORIGIN);
432 }
433}