use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileForReview {
pub path: String,
pub content: String,
#[serde(default)]
pub truncated: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PriorFinding {
pub path: String,
pub line: u32,
pub cap: String,
pub rule: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NovelFindingDiscoveryInput {
pub run_id: String,
pub repo: String,
pub batch_id: String,
pub files: Vec<FileForReview>,
#[serde(default)]
pub priors: Vec<PriorFinding>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CandidateFinding {
pub path: String,
pub line: u32,
pub cap: String,
#[serde(default)]
pub rule_hint: Option<String>,
pub rationale: String,
#[serde(default)]
pub suggested_payload_hint: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NovelFindingDiscoveryOutput {
#[serde(default)]
pub candidates: Vec<CandidateFinding>,
}
pub const NOVEL_FINDING_DISCOVERY_PROMPT_VERSION: &str = "phase17.novel_findings.v1";
pub const DEFAULT_FILES_PER_BATCH: usize = 30;
pub const DEFAULT_NOVEL_DISCOVERY_RUN_CAP_USD_MICROS: i64 = 5_000_000;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_roundtrips_through_serde() {
let inp = NovelFindingDiscoveryInput {
run_id: "run-1".into(),
repo: "repo-1".into(),
batch_id: "repo-1:0".into(),
files: vec![FileForReview {
path: "app/handlers.py".into(),
content: "def f(q):\n cursor.execute('SELECT '+q)\n".into(),
truncated: false,
}],
priors: vec![PriorFinding {
path: "app/handlers.py".into(),
line: 2,
cap: "SQL_QUERY".into(),
rule: "py.sql.exec".into(),
}],
};
let s = serde_json::to_string(&inp).unwrap();
let back: NovelFindingDiscoveryInput = serde_json::from_str(&s).unwrap();
assert_eq!(inp, back);
}
#[test]
fn output_roundtrips_through_serde() {
let out = NovelFindingDiscoveryOutput {
candidates: vec![CandidateFinding {
path: "app/handlers.py".into(),
line: 9,
cap: "SQL_QUERY".into(),
rule_hint: Some("py.sql.exec".into()),
rationale: "second helper builds SQL via string concat".into(),
suggested_payload_hint: Some("' OR 1=1 --".into()),
}],
};
let s = serde_json::to_string(&out).unwrap();
let back: NovelFindingDiscoveryOutput = serde_json::from_str(&s).unwrap();
assert_eq!(out, back);
}
#[test]
fn output_tolerates_missing_optional_fields() {
let raw = r#"{"candidates":[
{"path":"a.py","line":3,"cap":"OS_COMMAND","rationale":"shell call with user input"}
]}"#;
let parsed: NovelFindingDiscoveryOutput = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.candidates.len(), 1);
assert!(parsed.candidates[0].rule_hint.is_none());
assert!(parsed.candidates[0].suggested_payload_hint.is_none());
}
#[test]
fn output_tolerates_empty_candidates_array() {
let raw = r#"{"candidates":[]}"#;
let parsed: NovelFindingDiscoveryOutput = serde_json::from_str(raw).unwrap();
assert!(parsed.candidates.is_empty());
}
}