use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub id: Option<String>,
pub primary_path: PathBuf,
pub kind: Option<String>,
pub confidence: Option<f64>,
pub severity: Option<String>,
pub tier: Option<String>,
pub evidence: Option<String>,
pub suggested_action: Option<String>,
}
impl Finding {
pub fn language_hint(&self) -> &'static str {
match self.kind.as_deref() {
Some("doc_drift_link") | Some("doc_drift_keyword") => "markdown",
_ => crate::pack::lang_for_path(&self.primary_path),
}
}
pub fn descriptor(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(k) = &self.kind {
parts.push(k.clone());
}
match (self.severity.as_deref(), self.tier.as_deref()) {
(Some(s), Some(t)) => parts.push(format!("{s}/{t}")),
(Some(s), None) => parts.push(s.into()),
(None, Some(t)) => parts.push(t.into()),
(None, None) => {}
}
if let Some(c) = self.confidence {
parts.push(format!("conf={c:.2}"));
}
parts.join(", ")
}
}
pub fn parse_envelope(raw: &str) -> serde_json::Result<Vec<Finding>> {
let envelope: Value = serde_json::from_str(raw)?;
let findings = match envelope.get("findings").and_then(|v| v.as_array()) {
Some(a) => a,
None => return Ok(Vec::new()),
};
let mut out = Vec::with_capacity(findings.len());
for f in findings {
let Some(path) = pluck_primary_path(f) else {
continue;
};
out.push(Finding {
id: f.get("id").and_then(|v| v.as_str()).map(String::from),
primary_path: PathBuf::from(path),
kind: pluck_kind(f),
confidence: f.get("confidence").and_then(|v| v.as_f64()),
severity: f.get("severity").and_then(|v| v.as_str()).map(String::from),
tier: f.get("tier").and_then(|v| v.as_str()).map(String::from),
evidence: f.get("evidence").and_then(|v| v.as_str()).map(String::from),
suggested_action: f
.get("suggested_action")
.and_then(|v| v.as_str())
.map(String::from),
});
}
Ok(out)
}
fn pluck_kind(f: &Value) -> Option<String> {
match f.get("kind")? {
Value::String(s) => Some(s.clone()),
Value::Object(obj) => obj.keys().next().cloned(),
_ => None,
}
}
fn pluck_primary_path(finding: &Value) -> Option<String> {
if let Some(s) = finding.get("primary_path").and_then(|v| v.as_str()) {
return Some(s.to_string());
}
if let Some(s) = finding
.pointer("/impact_surface/primary_path")
.and_then(|v| v.as_str())
{
return Some(s.to_string());
}
if let Some(found) = walk_for_key(finding, "primary_path") {
return Some(found);
}
if let Some(s) = finding.get("path").and_then(|v| v.as_str()) {
return Some(s.to_string());
}
None
}
fn walk_for_key(v: &Value, key: &str) -> Option<String> {
match v {
Value::Object(map) => {
if let Some(val) = map.get(key)
&& let Some(s) = val.as_str()
{
return Some(s.to_string());
}
for child in map.values() {
if let Some(found) = walk_for_key(child, key) {
return Some(found);
}
}
None
}
Value::Array(items) => {
for item in items {
if let Some(found) = walk_for_key(item, key) {
return Some(found);
}
}
None
}
_ => None,
}
}
pub fn filter_and_sort(
mut findings: Vec<Finding>,
min_confidence: Option<f64>,
exclude_ids: &[String],
) -> Vec<Finding> {
use std::collections::HashSet;
let excluded: HashSet<&str> = exclude_ids.iter().map(String::as_str).collect();
findings.retain(|f| {
if let Some(id) = &f.id
&& excluded.contains(id.as_str())
{
return false;
}
if let Some(min) = min_confidence
&& let Some(c) = f.confidence
&& c < min
{
return false;
}
true
});
findings.sort_by(|a, b| {
let ac = a.confidence.unwrap_or(f64::NEG_INFINITY);
let bc = b.confidence.unwrap_or(f64::NEG_INFINITY);
bc.partial_cmp(&ac)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.primary_path.cmp(&b.primary_path))
});
findings
}
pub fn unique_paths(findings: &[Finding]) -> Vec<PathBuf> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::with_capacity(findings.len());
for f in findings {
if seen.insert(f.primary_path.clone()) {
out.push(f.primary_path.clone());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_top_level_primary_path() {
let raw = r#"{"findings":[{"primary_path":"src/foo.rs"}]}"#;
let fs = parse_envelope(raw).unwrap();
assert_eq!(fs.len(), 1);
assert_eq!(fs[0].primary_path, PathBuf::from("src/foo.rs"));
}
#[test]
fn parses_impact_surface_primary_path() {
let raw = r#"{"findings":[{"impact_surface":{"primary_path":"src/bar.rs"}}]}"#;
let fs = parse_envelope(raw).unwrap();
assert_eq!(fs[0].primary_path, PathBuf::from("src/bar.rs"));
}
#[test]
fn parses_kind_payload_primary_path() {
let raw = r#"{"findings":[{"kind":{"unsafe":{"primary_path":"src/ffi.rs"}}}]}"#;
let fs = parse_envelope(raw).unwrap();
assert_eq!(fs[0].primary_path, PathBuf::from("src/ffi.rs"));
assert_eq!(fs[0].kind.as_deref(), Some("unsafe"));
}
#[test]
fn parses_kind_as_string() {
let raw = r#"{"findings":[{"primary_path":"a.rs","kind":"trait_impl"}]}"#;
let fs = parse_envelope(raw).unwrap();
assert_eq!(fs[0].kind.as_deref(), Some("trait_impl"));
}
#[test]
fn parses_full_metadata() {
let raw = r#"{"findings":[{
"id":"f-abcd1234",
"primary_path":"src/foo.rs",
"kind":"trait_impl",
"confidence":0.85,
"severity":"high",
"tier":"likely",
"evidence":"Trait impl affects 3 downstream callers",
"suggested_action":"cargo nextest run -E 'test(foo)'"
}]}"#;
let fs = parse_envelope(raw).unwrap();
let f = &fs[0];
assert_eq!(f.id.as_deref(), Some("f-abcd1234"));
assert_eq!(f.kind.as_deref(), Some("trait_impl"));
assert_eq!(f.confidence, Some(0.85));
assert_eq!(f.severity.as_deref(), Some("high"));
assert_eq!(f.tier.as_deref(), Some("likely"));
assert!(
f.evidence
.as_deref()
.unwrap()
.starts_with("Trait impl affects")
);
assert!(f.suggested_action.as_deref().unwrap().contains("nextest"));
}
#[test]
fn skips_findings_without_path_silently() {
let raw = r#"{"findings":[
{"primary_path":"keep.rs"},
{"kind":"some_other_thing","tier":"low"},
{"primary_path":"also_keep.rs"}
]}"#;
let fs = parse_envelope(raw).unwrap();
assert_eq!(fs.len(), 2);
assert_eq!(fs[0].primary_path, PathBuf::from("keep.rs"));
assert_eq!(fs[1].primary_path, PathBuf::from("also_keep.rs"));
}
#[test]
fn empty_envelope_returns_empty() {
assert!(parse_envelope(r#"{}"#).unwrap().is_empty());
assert!(parse_envelope(r#"{"findings":[]}"#).unwrap().is_empty());
}
#[test]
fn malformed_json_errors() {
assert!(parse_envelope("{ not json").is_err());
}
#[test]
fn ignores_unknown_top_level_and_per_finding_fields() {
let raw = r#"{
"version":"0.3.0",
"summary":{"total":1},
"future_field":{"nested":true},
"findings":[{
"primary_path":"a.rs",
"new_field_in_v0_4":"ignored"
}]
}"#;
let fs = parse_envelope(raw).unwrap();
assert_eq!(fs.len(), 1);
assert_eq!(fs[0].primary_path, PathBuf::from("a.rs"));
}
#[test]
fn filter_drops_below_min_confidence_keeps_unknown() {
let findings = vec![
mk_finding("a.rs", Some("f1"), Some(0.95)),
mk_finding("b.rs", Some("f2"), Some(0.40)),
mk_finding("c.rs", Some("f3"), None),
];
let out = filter_and_sort(findings, Some(0.8), &[]);
let ids: Vec<_> = out.iter().map(|f| f.id.clone().unwrap()).collect();
assert!(ids.contains(&"f1".to_string()));
assert!(!ids.contains(&"f2".to_string()));
assert!(
ids.contains(&"f3".to_string()),
"unknown-confidence finding should survive: {ids:?}"
);
}
#[test]
fn filter_drops_excluded_ids() {
let findings = vec![
mk_finding("a.rs", Some("f1"), Some(0.95)),
mk_finding("b.rs", Some("f2"), Some(0.90)),
mk_finding("c.rs", Some("f3"), Some(0.90)),
];
let out = filter_and_sort(findings, None, &["f2".into(), "f3".into()]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].id.as_deref(), Some("f1"));
}
#[test]
fn sort_by_confidence_desc_with_stable_tiebreak() {
let findings = vec![
mk_finding("c.rs", Some("c"), Some(0.5)),
mk_finding("a.rs", Some("a"), Some(0.9)),
mk_finding("b.rs", Some("b"), Some(0.9)),
mk_finding("d.rs", Some("d"), None),
];
let out = filter_and_sort(findings, None, &[]);
let ids: Vec<_> = out.iter().map(|f| f.id.clone().unwrap()).collect();
assert_eq!(ids, vec!["a", "b", "c", "d"]);
}
#[test]
fn unique_paths_dedupes_preserving_order() {
let findings = vec![
mk_finding("a.rs", Some("f1"), Some(0.9)),
mk_finding("b.rs", Some("f2"), Some(0.8)),
mk_finding("a.rs", Some("f3"), Some(0.7)),
];
let paths = unique_paths(&findings);
assert_eq!(paths, vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")]);
}
#[test]
fn language_hint_kind_aware_overrides_extension() {
let f = mk_kind("README", "doc_drift_link");
assert_eq!(f.language_hint(), "markdown");
let f = mk_kind("notes", "doc_drift_keyword");
assert_eq!(f.language_hint(), "markdown");
let f = mk_kind("src/foo.rs", "trait_impl");
assert_eq!(f.language_hint(), "rust");
}
#[test]
fn descriptor_combines_metadata() {
let mut f = mk_finding("a.rs", Some("f1"), Some(0.85));
f.kind = Some("trait_impl".into());
f.severity = Some("high".into());
f.tier = Some("likely".into());
assert_eq!(f.descriptor(), "trait_impl, high/likely, conf=0.85");
}
fn mk_finding(path: &str, id: Option<&str>, confidence: Option<f64>) -> Finding {
Finding {
id: id.map(String::from),
primary_path: PathBuf::from(path),
kind: None,
confidence,
severity: None,
tier: None,
evidence: None,
suggested_action: None,
}
}
fn mk_kind(path: &str, kind: &str) -> Finding {
Finding {
id: None,
primary_path: PathBuf::from(path),
kind: Some(kind.into()),
confidence: None,
severity: None,
tier: None,
evidence: None,
suggested_action: None,
}
}
}