Skip to main content

difflore_core/domain/
origins.rs

1//! Origin taxonomy: id → label / color / base confidence.
2
3pub 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
79/// Recommended display priority: evidence-bearing origins
80/// (`pr_review`, `extracted`) sort first, everything else after.
81/// Used by `rules list --view recommended` and any other
82/// "high-value first" surface.
83pub fn sort_order(origin: &str) -> u8 {
84    match origin {
85        "pr_review" | "extracted" => 0,
86        _ => 1,
87    }
88}
89
90/// Distribution-histogram ordering for the rule mix UI in the TUI:
91/// frequency / value mixed, finer-grained than `sort_order`. Stable
92/// ordering across releases is the contract — callers rely on this for
93/// chart layout.
94pub 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
105/// Group rules by their origin string. Returns a `BTreeMap` so callers
106/// get deterministic key iteration without a sort step.
107pub 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
117/// Generic HTTP / network / timeout error classifier shared by
118/// domain-specific formatters (`cli::format_cloud_err`,
119/// `cli::format_github_import_err`). Returns a user-facing message;
120/// keeps the raw error in the output on the unrecognised path so triage
121/// info isn't lost.
122///
123/// Domain-specific framings (cloud BYOK guidance, GitHub auth hints)
124/// belong in the cli wrappers — this layer must not bake product
125/// language for a specific surface.
126pub 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        // Iterate ORIGINS directly so adding a new entry is auto-covered;
178        // a hand-maintained list silently skipped new origins until a
179        // reviewer noticed and updated the test.
180        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            // Round-trip the public lookups so the helpers stay aligned
197            // with the table.
198            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}