1pub struct OriginDef {
4 pub id: &'static str,
5 pub label: &'static str,
6 pub description: &'static str,
7 pub color_hex: &'static str,
8 pub base_confidence: f64,
9}
10
11pub const ORIGINS: &[OriginDef] = &[
12 OriginDef {
13 id: "manual",
14 label: "Manual",
15 description: "Rule entered via the CLI by a human operator.",
16 color_hex: "#9CA0B0",
17 base_confidence: 0.7,
18 },
19 OriginDef {
20 id: "conversation",
21 label: "Conversation",
22 description: "Rule captured mid-session via the remember_rule MCP tool.",
23 color_hex: "#CBA6F7",
24 base_confidence: 0.6,
25 },
26 OriginDef {
27 id: "pr_review",
28 label: "PR Review",
29 description: "Rule extracted from a pull-request review comment.",
30 color_hex: "#89DCEB",
31 base_confidence: 0.7,
32 },
33 OriginDef {
34 id: "extracted",
35 label: "Extracted",
36 description: "Rule distilled from existing docs or codebase signal.",
37 color_hex: "#A6E3A1",
38 base_confidence: 0.65,
39 },
40 OriginDef {
41 id: "cloud",
42 label: "Cloud",
43 description: "Rule synced from the DiffLore cloud catalogue.",
44 color_hex: "#F9E2AF",
45 base_confidence: 0.7,
46 },
47 OriginDef {
48 id: "team",
49 label: "Team",
50 description: "Rule shared by a team the user belongs to.",
51 color_hex: "#89B4FA",
52 base_confidence: 0.75,
53 },
54 OriginDef {
55 id: "agent-memory",
56 label: "Agent memory",
57 description: "Rule extracted from a coding agent's local memory or rules file.",
58 color_hex: "#94E2D5",
59 base_confidence: 0.6,
60 },
61];
62
63pub fn origin(id: &str) -> Option<&'static OriginDef> {
64 ORIGINS.iter().find(|o| o.id == id)
65}
66
67pub fn color_hex_for(id: &str) -> Option<&'static str> {
68 origin(id).map(|o| o.color_hex)
69}
70
71pub fn label_for(id: &str) -> Option<&'static str> {
72 origin(id).map(|o| o.label)
73}
74
75pub fn base_confidence_for(id: &str) -> Option<f64> {
76 origin(id).map(|o| o.base_confidence)
77}
78
79pub fn sort_order(origin: &str) -> u8 {
84 match origin {
85 "pr_review" | "extracted" => 0,
86 _ => 1,
87 }
88}
89
90pub fn distribution_sort_key(origin: &str) -> u8 {
95 match origin {
96 "conversation" => 0,
97 "manual" => 1,
98 "pr_review" => 2,
99 "extracted" => 3,
100 "cloud" => 4,
101 _ => 5,
102 }
103}
104
105pub fn group_by_origin<'a, R: crate::domain::rule_view::RuleView>(
108 rules: &'a [R],
109) -> std::collections::BTreeMap<String, Vec<&'a R>> {
110 let mut out: std::collections::BTreeMap<String, Vec<&'a R>> = std::collections::BTreeMap::new();
111 for r in rules {
112 out.entry(r.origin().to_owned()).or_default().push(r);
113 }
114 out
115}
116
117pub fn format_api_error(label: &str, raw: &str) -> String {
127 let lower = raw.to_ascii_lowercase();
128 if raw.contains("API error 401") || raw.contains("status\":401") {
129 return format!("{label}: session expired or revoked (401).\n\n raw: {raw}");
130 }
131 if raw.contains("API error 403") || raw.contains("status\":403") {
132 return format!("{label}: request rejected (403).\n\n raw: {raw}");
133 }
134 if raw.contains("API error 429") || raw.contains("status\":429") {
135 return format!("{label}: rate-limited (429).\n\n raw: {raw}");
136 }
137 if raw.contains("API error 5") || raw.contains("INTERNAL_SERVER_ERROR") {
138 return format!("{label}: server error (5xx). Likely transient.\n\n raw: {raw}");
139 }
140 if lower.contains("connection refused")
141 || lower.contains("connect error")
142 || lower.contains("dns")
143 || lower.contains("connection reset")
144 || lower.contains("network is unreachable")
145 || lower.contains("actively refused")
146 || lower.contains("os error 10061")
147 || (lower.contains("error sending request") && lower.contains("localhost"))
148 {
149 return format!(
150 "{label}: network unreachable (DNS or connectivity issue).\n\n raw: {raw}"
151 );
152 }
153 if lower.contains("timed out") || lower.contains("timeout") {
154 return format!("{label}: request timed out.\n\n raw: {raw}");
155 }
156 format!("{label}: {raw}")
157}
158
159pub fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> {
160 let s = hex.trim();
161 let s = s.strip_prefix('#').unwrap_or(s);
162 if s.len() != 6 {
163 return None;
164 }
165 let r = u8::from_str_radix(&s[0..2], 16).ok()?;
166 let g = u8::from_str_radix(&s[2..4], 16).ok()?;
167 let b = u8::from_str_radix(&s[4..6], 16).ok()?;
168 Some((r, g, b))
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn all_origins_have_valid_fields() {
177 assert!(!ORIGINS.is_empty(), "ORIGINS taxonomy is empty");
181 for def in ORIGINS {
182 assert!(!def.id.is_empty(), "origin id is empty");
183 assert!(!def.label.is_empty(), "origin {} has empty label", def.id);
184 assert!(
185 parse_hex_rgb(def.color_hex).is_some(),
186 "origin {} color {} fails to parse",
187 def.id,
188 def.color_hex
189 );
190 assert!(
191 def.base_confidence > 0.0 && def.base_confidence <= 1.0,
192 "origin {} confidence {} out of (0,1]",
193 def.id,
194 def.base_confidence
195 );
196 assert_eq!(origin(def.id).map(|o| o.label), Some(def.label));
199 assert_eq!(color_hex_for(def.id), Some(def.color_hex));
200 assert_eq!(label_for(def.id), Some(def.label));
201 assert_eq!(base_confidence_for(def.id), Some(def.base_confidence));
202 }
203 assert!(origin("does-not-exist").is_none());
204 assert!(color_hex_for("does-not-exist").is_none());
205 assert!(label_for("does-not-exist").is_none());
206 assert!(base_confidence_for("does-not-exist").is_none());
207 }
208
209 #[test]
210 fn sort_order_prioritises_evidence_origins() {
211 assert!(sort_order("pr_review") < sort_order("manual"));
212 assert!(sort_order("extracted") < sort_order("conversation"));
213 assert!(sort_order("manual") == sort_order("cloud"));
214 assert!(sort_order("totally-unknown") == sort_order("cloud"));
215 }
216
217 #[test]
218 fn distribution_sort_key_six_buckets() {
219 assert_eq!(distribution_sort_key("conversation"), 0);
220 assert_eq!(distribution_sort_key("manual"), 1);
221 assert_eq!(distribution_sort_key("pr_review"), 2);
222 assert_eq!(distribution_sort_key("extracted"), 3);
223 assert_eq!(distribution_sort_key("cloud"), 4);
224 assert_eq!(distribution_sort_key("agent-memory"), 5);
225 assert_eq!(distribution_sort_key("unknown-x"), 5);
226 }
227
228 #[test]
229 fn group_by_origin_buckets_rules() {
230 use crate::domain::rule_view::RuleView;
231 struct R {
232 id: String,
233 origin: String,
234 }
235 impl RuleView for R {
236 fn id(&self) -> &str {
237 &self.id
238 }
239 fn content(&self) -> &str {
240 &self.id[..0]
241 }
242 fn origin(&self) -> &str {
243 &self.origin
244 }
245 fn confidence(&self) -> Option<f64> {
246 None
247 }
248 }
249 let rules = vec![
250 R {
251 id: "a".into(),
252 origin: "pr_review".into(),
253 },
254 R {
255 id: "b".into(),
256 origin: "manual".into(),
257 },
258 R {
259 id: "c".into(),
260 origin: "pr_review".into(),
261 },
262 ];
263 let grouped = group_by_origin(&rules);
264 assert_eq!(grouped.get("pr_review").map(Vec::len), Some(2));
265 assert_eq!(grouped.get("manual").map(Vec::len), Some(1));
266 assert!(!grouped.contains_key("missing"));
267 }
268
269 #[test]
270 fn format_api_error_classifies_4xx_5xx_network_timeout_fallback() {
271 let s = format_api_error("Sync", "API error 401: token revoked");
272 assert!(s.contains("session expired"));
273 assert!(s.contains("token revoked"), "raw retained: {s}");
274
275 let s = format_api_error("Sync", "API error 429: too many");
276 assert!(s.contains("rate-limited"));
277
278 let s = format_api_error("Sync", r#"API error 500: {"code":"INTERNAL_SERVER_ERROR"}"#);
279 assert!(s.contains("server error"));
280
281 let s = format_api_error("Sync", "request timed out after 30s");
282 assert!(s.contains("timed out"));
283
284 let s = format_api_error("Sync", "connection refused");
285 assert!(s.to_lowercase().contains("unreachable"));
286
287 let s = format_api_error("Sync", "os error 10061");
288 assert!(s.to_lowercase().contains("unreachable"));
289
290 let s = format_api_error("Sync", "totally novel xyz");
291 assert!(s.contains("totally novel xyz"));
292 }
293
294 #[test]
295 fn parse_hex_rgb_round_trip() {
296 assert_eq!(parse_hex_rgb("#CBA6F7"), Some((0xcb, 0xa6, 0xf7)));
297 assert_eq!(parse_hex_rgb("CBA6F7"), Some((0xcb, 0xa6, 0xf7)));
298 assert_eq!(parse_hex_rgb("#cba6f7"), Some((0xcb, 0xa6, 0xf7)));
299 assert!(parse_hex_rgb("#XYZ123").is_none());
300 assert!(parse_hex_rgb("#ABC").is_none());
301 assert!(parse_hex_rgb("").is_none());
302 }
303}