1use crate::context::rule_source::{RuleExample, repo_scope_from_source_repo};
26use crate::skills::{parse_candidate_drafted_rule, parse_candidate_source_proof};
27
28const REVIEWER_EXCERPT_RENDER_LIMIT: usize = 300;
32
33const RATIONALE_SENTENCE_LIMIT: usize = 2;
37
38pub struct RuleRenderInput<'a> {
42 pub id: &'a str,
43 pub name: &'a str,
44 pub r#type: &'a str,
45 pub confidence: f64,
46 pub origin: &'a str,
47 pub source_repo: Option<&'a str>,
49 pub file_patterns: &'a [String],
52 pub description: &'a str,
56 pub trigger: Option<&'a str>,
58 pub check_prompt: Option<&'a str>,
60 pub examples: Option<&'a [RuleExample]>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Polarity {
68 Must,
69 Avoid,
70}
71
72impl Polarity {
73 const fn label(self) -> &'static str {
74 match self {
75 Self::Must => "MUST",
76 Self::Avoid => "AVOID",
77 }
78 }
79}
80
81#[must_use]
85pub fn directive_polarity(statement: &str) -> Polarity {
86 let lower = statement.to_ascii_lowercase();
87 const AVOID_MARKERS: &[&str] = &[
88 "avoid",
89 "don't",
90 "do not",
91 "dont",
92 "never",
93 "no longer",
94 "must not",
95 "mustn't",
96 "should not",
97 "shouldn't",
98 "stop ",
99 ];
100 if AVOID_MARKERS.iter().any(|m| lower.contains(m)) {
101 Polarity::Avoid
102 } else {
103 Polarity::Must
104 }
105}
106
107fn origin_label(origin: &str) -> &str {
111 match origin {
112 "pr_review" => "PR review",
113 "conversation" => "remembered in conversation",
114 "extracted" => "extracted",
115 "manual" => "manual",
116 "cloud" => "cloud-synced",
117 "team" => "team-synced",
118 other => other,
119 }
120}
121
122fn first_sentences(text: &str, limit: usize) -> String {
127 let trimmed = text.trim();
128 if trimmed.is_empty() || limit == 0 {
129 return trimmed.to_owned();
130 }
131 let mut out = String::new();
132 let mut count = 0usize;
133 let mut chars = trimmed.chars().peekable();
134 while let Some(c) = chars.next() {
135 out.push(c);
136 if matches!(c, '.' | '!' | '?') && chars.peek().is_none_or(|n| n.is_whitespace()) {
137 count += 1;
138 if count >= limit {
139 break;
140 }
141 }
142 }
143 out.trim().to_owned()
144}
145
146fn truncate_with_ellipsis(s: &str, limit: usize) -> String {
149 let mut chars = s.chars();
150 let head: String = chars.by_ref().take(limit).collect();
151 if chars.next().is_some() {
152 format!("{head}...")
153 } else {
154 head
155 }
156}
157
158#[must_use]
164pub fn render_contract_block(origin: &str, description: &str) -> String {
165 let mut out = String::from("### Contract\n");
166
167 if origin == "pr_review"
169 && let Some(stmt) = parse_candidate_drafted_rule(description)
170 && !stmt.trim().is_empty()
171 {
172 let stmt = stmt.trim();
173 let polarity = directive_polarity(stmt);
174 out.push_str(&format!("- {}: {}\n", polarity.label(), stmt));
175 return out;
176 }
177
178 let trimmed = description.trim();
181 if let Some((first, rest)) = split_directive_and_rationale(trimmed) {
182 let polarity = directive_polarity(&first);
183 out.push_str(&format!("- {}: {}\n", polarity.label(), first.trim()));
184 if let Some(rest) = rest {
185 let rationale = first_sentences(&rest, RATIONALE_SENTENCE_LIMIT);
186 if !rationale.is_empty() {
187 out.push_str(&format!("\nRationale: {rationale}\n"));
188 }
189 }
190 return out;
191 }
192
193 if trimmed.is_empty() {
198 out.push_str("- (no rule body)\n");
200 } else {
201 out.push_str(&format!("{trimmed}\n"));
202 }
203 out
204}
205
206fn split_directive_and_rationale(body: &str) -> Option<(String, Option<String>)> {
210 let body = body.trim();
211 if body.is_empty() {
212 return None;
213 }
214 let first = first_sentences(body, 1);
216 if first.is_empty() {
217 return None;
218 }
219 let rest = body
220 .get(first.len()..)
221 .map(str::trim)
222 .filter(|r| !r.is_empty())
223 .map(ToOwned::to_owned);
224 Some((first, rest))
225}
226
227#[must_use]
235pub fn render_validation_matrix(
236 origin: &str,
237 description: &str,
238 examples: Option<&[RuleExample]>,
239) -> Option<String> {
240 let mut rows: Vec<(String, String, String)> = Vec::new();
241
242 if let Some(ex) = examples.and_then(|e| e.first()) {
244 let condition = matrix_cell(&ex.bad_code);
245 let expected = matrix_cell(&ex.good_code);
246 let on_violation = ex
247 .description
248 .as_deref()
249 .map(str::trim)
250 .filter(|d| !d.is_empty())
251 .map_or_else(|| "reviewer flagged this".to_owned(), matrix_cell);
252 rows.push((condition, expected, on_violation));
253 }
254
255 if origin == "pr_review"
257 && let Some(stmt) = parse_candidate_drafted_rule(description)
258 && let Some(row) = when_directive_row(&stmt)
259 {
260 rows.push(row);
261 }
262
263 if rows.is_empty() {
264 return None;
265 }
266
267 let mut out = String::from("### Validation / Error matrix\n");
268 out.push_str("| Condition | Expected | On violation |\n");
269 out.push_str("|---|---|---|\n");
270 for (cond, expected, on_violation) in rows {
271 out.push_str(&format!("| {cond} | {expected} | {on_violation} |\n"));
272 }
273 Some(out)
274}
275
276fn when_directive_row(statement: &str) -> Option<(String, String, String)> {
281 let stmt = statement.trim();
282 let lower = stmt.to_ascii_lowercase();
283 if !lower.starts_with("when ") {
284 return None;
285 }
286 let (cond, expected) = stmt.split_once(',')?;
287 let cond = cond.trim();
288 let expected = expected.trim().trim_end_matches('.').trim();
289 if cond.is_empty() || expected.is_empty() {
290 return None;
291 }
292 Some((
293 matrix_cell(cond),
294 matrix_cell(expected),
295 "directive applies".to_owned(),
296 ))
297}
298
299fn matrix_cell(value: &str) -> String {
303 let flat: String = value
304 .trim()
305 .chars()
306 .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
307 .collect();
308 let escaped = flat.replace('|', "\\|");
309 truncate_with_ellipsis(escaped.trim(), 80)
310}
311
312#[must_use]
316pub fn render_cases_block(examples: Option<&[RuleExample]>) -> Option<String> {
317 let examples = examples.filter(|e| !e.is_empty())?;
318 let mut out = String::from("### Cases\n");
319 for ex in examples {
320 out.push_str(&format!(
321 "❌ Counter-example:\n```\n{}\n```\n\n✅ Conforming:\n```\n{}\n```\n",
322 ex.bad_code, ex.good_code
323 ));
324 if let Some(desc) = ex.description.as_deref().map(str::trim)
325 && !desc.is_empty()
326 {
327 out.push_str(&format!("\n{desc}\n"));
328 }
329 }
330 Some(out)
331}
332
333#[must_use]
340pub fn render_provenance_block(description: &str, source_repo: Option<&str>) -> Option<String> {
341 let proof = parse_candidate_source_proof(description);
342 let has_repo = source_repo.map(str::trim).is_some_and(|r| !r.is_empty());
343 if proof.is_none() && !has_repo {
344 return None;
345 }
346
347 let mut out = String::from("### Provenance\n");
348 if let Some(proof) = proof.as_ref() {
349 let source = proof
350 .source
351 .as_deref()
352 .map(str::trim)
353 .filter(|s| !s.is_empty());
354 let comment = proof
355 .comment_url
356 .as_deref()
357 .map(str::trim)
358 .filter(|s| !s.is_empty());
359 match (source, comment) {
360 (Some(s), Some(c)) => out.push_str(&format!("Source: {s} · {c}\n")),
361 (Some(s), None) => out.push_str(&format!("Source: {s}\n")),
362 (None, Some(c)) => out.push_str(&format!("Source: {c}\n")),
363 (None, None) => {}
364 }
365 if let Some(excerpt) = proof
366 .excerpt
367 .as_deref()
368 .map(str::trim)
369 .filter(|s| !s.is_empty())
370 {
371 let excerpt = truncate_with_ellipsis(excerpt, REVIEWER_EXCERPT_RENDER_LIMIT);
372 out.push_str(&format!("Reviewer: {excerpt}\n"));
373 }
374 }
375 if out == "### Provenance\n" {
379 return None;
380 }
381 Some(out)
382}
383
384#[must_use]
388pub fn render_code_spec(input: &RuleRenderInput<'_>) -> String {
389 let mut out = String::new();
390
391 out.push_str(&format!("## Rule {} — {}\n", input.id, input.name));
393
394 let scope = if input.file_patterns.is_empty() {
396 "repo-wide (no file scope)".to_owned()
397 } else {
398 input.file_patterns.join(", ")
399 };
400 out.push_str(&format!("Scope: {scope}\n"));
401
402 let learned_from = repo_scope_from_source_repo(input.source_repo)
404 .map(|s| format!(" \u{2190} learned from {s}"))
405 .unwrap_or_default();
406 out.push_str(&format!(
407 "Type: {} · Confidence: {:.2} · Origin: {}{}\n",
408 input.r#type,
409 input.confidence,
410 origin_label(input.origin),
411 learned_from,
412 ));
413
414 out.push('\n');
416 out.push_str(&render_contract_block(input.origin, input.description));
417
418 if let Some(matrix) = render_validation_matrix(input.origin, input.description, input.examples)
420 {
421 out.push('\n');
422 out.push_str(&matrix);
423 }
424
425 if let Some(trigger) = input.trigger.map(str::trim).filter(|t| !t.is_empty()) {
427 out.push_str(&format!("\n### Trigger\n{trigger}\n"));
428 }
429
430 if let Some(check) = input.check_prompt.map(str::trim).filter(|c| !c.is_empty()) {
432 out.push_str(&format!("\n### Self-check\n{check}\n"));
433 }
434
435 if let Some(cases) = render_cases_block(input.examples) {
437 out.push('\n');
438 out.push_str(&cases);
439 }
440
441 if let Some(prov) = render_provenance_block(input.description, input.source_repo) {
443 out.push('\n');
444 out.push_str(&prov);
445 }
446
447 out
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use crate::context::rule_source::RuleExample;
454
455 fn example(bad: &str, good: &str, desc: Option<&str>) -> RuleExample {
456 RuleExample {
457 id: "ex1".to_owned(),
458 skill_id: "rule1".to_owned(),
459 bad_code: bad.to_owned(),
460 good_code: good.to_owned(),
461 description: desc.map(ToOwned::to_owned),
462 source: "test".to_owned(),
463 }
464 }
465
466 #[test]
467 fn directive_polarity_classifies_negatives_as_avoid() {
468 assert_eq!(
469 directive_polarity("never unwrap in handlers"),
470 Polarity::Avoid
471 );
472 assert_eq!(directive_polarity("Avoid magic numbers"), Polarity::Avoid);
473 assert_eq!(directive_polarity("don't swallow errors"), Polarity::Avoid);
474 assert_eq!(
475 directive_polarity("Should not panic in hot paths"),
476 Polarity::Avoid
477 );
478 }
479
480 #[test]
481 fn directive_polarity_defaults_to_must() {
482 assert_eq!(
483 directive_polarity("prefer structured parsing in resolve"),
484 Polarity::Must
485 );
486 assert_eq!(
487 directive_polarity("return a structured error instead"),
488 Polarity::Must
489 );
490 }
491
492 #[test]
493 fn first_sentences_caps_at_limit_and_roundtrips_shorter() {
494 assert_eq!(first_sentences("One. Two. Three.", 2), "One. Two.");
495 assert_eq!(first_sentences("Only one sentence", 2), "Only one sentence");
496 assert_eq!(first_sentences("", 2), "");
497 }
498
499 #[test]
500 fn contract_from_mined_rule_renders_must_bullet() {
501 let desc = "Rule:\nWhen touching `src/**/*.rs`, prefer structured parsing.\n\nSource evidence:\nSource: acme/widgets#7\n\nReviewer said:\nPlease prefer structured parsing.";
502 let block = render_contract_block("pr_review", desc);
503 assert!(block.starts_with("### Contract\n"));
504 assert!(
505 block.contains("- MUST: When touching `src/**/*.rs`, prefer structured parsing."),
506 "got: {block}"
507 );
508 }
509
510 #[test]
511 fn contract_from_mined_avoid_rule_renders_avoid_bullet() {
512 let desc = "Rule:\nWhen touching `src/http`, never unwrap request payloads.\n\nSource evidence:\nSource: acme/widgets#7";
513 let block = render_contract_block("pr_review", desc);
514 assert!(block.contains("- AVOID:"), "got: {block}");
515 }
516
517 #[test]
518 fn contract_from_conversation_rule_splits_directive_and_rationale() {
519 let desc = "Prefer dependency injection for clients. It makes the handler testable and avoids hidden globals.";
520 let block = render_contract_block("conversation", desc);
521 assert!(block.contains("- MUST: Prefer dependency injection for clients."));
522 assert!(block.contains("Rationale: It makes the handler testable"));
523 }
524
525 #[test]
526 fn contract_never_drops_body_when_unparseable() {
527 let desc = "Some freeform mined note without the structured shape";
529 let block = render_contract_block("pr_review", desc);
530 assert!(block.contains("Some freeform mined note"), "got: {block}");
531 }
532
533 #[test]
534 fn validation_matrix_row_from_example() {
535 let ex = [example(
536 "foo.unwrap()",
537 "foo?",
538 Some("reviewer asked for ?"),
539 )];
540 let matrix =
541 render_validation_matrix("conversation", "irrelevant", Some(&ex)).expect("matrix");
542 assert!(matrix.contains("| Condition | Expected | On violation |"));
543 assert!(matrix.contains("foo.unwrap()"));
544 assert!(matrix.contains("foo?"));
545 assert!(matrix.contains("reviewer asked for ?"));
546 }
547
548 #[test]
549 fn validation_matrix_row_from_when_directive() {
550 let desc = "Rule:\nWhen touching `src/http`, return a structured error.\n\nSource evidence:\nSource: acme/widgets#7";
551 let matrix = render_validation_matrix("pr_review", desc, None).expect("matrix");
552 assert!(matrix.contains("When touching `src/http`"));
553 assert!(matrix.contains("return a structured error"));
554 }
555
556 #[test]
557 fn validation_matrix_omitted_when_no_source() {
558 assert!(render_validation_matrix("conversation", "freeform prose", None).is_none());
559 }
560
561 #[test]
562 fn matrix_cell_escapes_pipes_and_collapses_newlines() {
563 assert_eq!(matrix_cell("a | b\nc"), "a \\| b c");
564 }
565
566 #[test]
567 fn cases_block_uses_conformance_framing_not_bad_good() {
568 let ex = [example("bad()", "good()", None)];
569 let block = render_cases_block(Some(&ex)).expect("cases");
570 assert!(block.contains("❌ Counter-example:"));
571 assert!(block.contains("✅ Conforming:"));
572 assert!(!block.contains("### Examples"));
574 }
575
576 #[test]
577 fn cases_block_omitted_when_empty() {
578 assert!(render_cases_block(None).is_none());
579 let empty: [RuleExample; 0] = [];
580 assert!(render_cases_block(Some(&empty)).is_none());
581 }
582
583 #[test]
584 fn provenance_from_mined_rule_includes_source_and_reviewer() {
585 let desc = "Rule:\nPrefer X.\n\nSource evidence:\nSource: acme/widgets#7\nComment: https://example.com/c/1\n\nReviewer said:\nPlease prefer X over Y.";
586 let prov = render_provenance_block(desc, Some("acme/widgets")).expect("provenance");
587 assert!(prov.contains("Source: acme/widgets#7"));
588 assert!(prov.contains("https://example.com/c/1"));
589 assert!(prov.contains("Reviewer: Please prefer X over Y."));
590 }
591
592 #[test]
593 fn provenance_omitted_for_conversation_rule_without_proof() {
594 assert!(render_provenance_block("freeform note", None).is_none());
597 }
598
599 #[test]
600 fn golden_mined_rule_with_example() {
601 let ex = [example(
602 "let v = resolve(x).unwrap();",
603 "let v = resolve(x)?;",
604 Some("reviewer flagged unwrap"),
605 )];
606 let input = RuleRenderInput {
607 id: "conv-foo-ab12",
608 name: "Prefer structured parsing in resolve",
609 r#type: "review_standard",
610 confidence: 0.82,
611 origin: "pr_review",
612 source_repo: Some("vitejs/vite"),
613 file_patterns: &["packages/vite/src/**/*.ts".to_owned()],
614 description: "Rule:\nWhen touching `packages/vite/src`, never unwrap resolve results.\n\nSource evidence:\nSource: vitejs/vite#42\nComment: https://example.com/pr/42#c\nFile: resolve.ts\n\nReviewer said:\nPlease return a structured error.",
615 trigger: None,
616 check_prompt: None,
617 examples: Some(&ex),
618 };
619 let body = render_code_spec(&input);
620 assert!(body.starts_with("## Rule conv-foo-ab12 — Prefer structured parsing in resolve\n"));
621 assert!(body.contains("Scope: packages/vite/src/**/*.ts"));
622 assert!(body.contains("Confidence: 0.82"));
623 assert!(body.contains("\u{2190} learned from vitejs/vite"));
624 assert!(body.contains("### Contract"));
625 assert!(
626 body.contains(
627 "- AVOID: When touching `packages/vite/src`, never unwrap resolve results."
628 )
629 );
630 assert!(body.contains("### Validation / Error matrix"));
631 assert!(body.contains("### Cases"));
632 assert!(body.contains("❌ Counter-example:"));
633 assert!(body.contains("### Provenance"));
634 assert!(body.contains("Source: vitejs/vite#42"));
635 assert!(!body.contains("### Trigger"));
637 assert!(!body.contains("### Self-check"));
638 }
639
640 #[test]
641 fn golden_conversation_rule_with_neither_trigger_nor_example() {
642 let input = RuleRenderInput {
643 id: "conv-bare-1",
644 name: "Keep handlers thin",
645 r#type: "review_standard",
646 confidence: 0.5,
647 origin: "conversation",
648 source_repo: None,
649 file_patterns: &[],
650 description: "Keep request handlers thin and push logic into services.",
651 trigger: None,
652 check_prompt: None,
653 examples: None,
654 };
655 let body = render_code_spec(&input);
656 assert!(body.contains("## Rule conv-bare-1 — Keep handlers thin"));
657 assert!(body.contains("Scope: repo-wide (no file scope)"));
658 assert!(body.contains("### Contract"));
659 assert!(body.contains("- MUST: Keep request handlers thin"));
660 assert!(!body.contains("### Validation / Error matrix"));
662 assert!(!body.contains("### Trigger"));
663 assert!(!body.contains("### Self-check"));
664 assert!(!body.contains("### Cases"));
665 assert!(!body.contains("### Provenance"));
666 }
667
668 #[test]
669 fn golden_rule_with_check_prompt_only() {
670 let input = RuleRenderInput {
671 id: "team-rule-9",
672 name: "Validate webhook signatures",
673 r#type: "review_standard",
674 confidence: 0.9,
675 origin: "team",
676 source_repo: None,
677 file_patterns: &["src/webhooks/**/*.ts".to_owned()],
678 description: "Always verify the HMAC signature before processing a webhook.",
679 trigger: None,
680 check_prompt: Some("Did you verify the signature before reading the body?"),
681 examples: None,
682 };
683 let body = render_code_spec(&input);
684 assert!(body.contains("### Self-check"));
685 assert!(body.contains("Did you verify the signature before reading the body?"));
686 assert!(!body.contains("### Trigger"));
687 assert!(!body.contains("### Cases"));
688 }
689}