1use crate::parity_metadata::{atomic_write_file, parse_key_value_content};
10use std::hash::{Hash, Hasher};
11use std::path::PathBuf;
12
13pub const FEEDBACK_TOOL_BUG_REPORT: &str = "bug_report";
14pub const FEEDBACK_TOOL_IMPROVEMENT_SUGGESTION: &str = "improvement_suggestion";
15
16fn normalize_text(value: &str) -> String {
17 value.split_whitespace().collect::<Vec<_>>().join(" ")
18}
19
20#[derive(Debug, Clone, PartialEq)]
21pub struct FeedbackInput {
22 pub title: String,
23 pub body: String,
24 pub evidence: Vec<String>,
25 pub session: String,
26 pub source: String,
27 pub confidence: f64,
28}
29
30pub fn validate_feedback_tool_input(tool: &str, input: &FeedbackInput) -> Result<(), String> {
31 if tool != FEEDBACK_TOOL_BUG_REPORT && tool != FEEDBACK_TOOL_IMPROVEMENT_SUGGESTION {
32 return Err(format!("Unknown feedback tool: {tool}"));
33 }
34 let title = normalize_text(&input.title);
35 let body = normalize_text(&input.body);
36 let session = normalize_text(&input.session);
37 let source = normalize_text(&input.source);
38 if title.is_empty()
39 || body.is_empty()
40 || session.is_empty()
41 || source.is_empty()
42 || input.evidence.is_empty()
43 {
44 return Err("Missing required fields".into());
45 }
46 if !input.confidence.is_finite() || input.confidence < 0.0 || input.confidence > 1.0 {
47 return Err("Invalid confidence".into());
48 }
49 Ok(())
50}
51
52pub fn generate_feedback_dedupe_key(tool: &str, input: &FeedbackInput) -> String {
53 let mut evidence: Vec<String> = input
54 .evidence
55 .iter()
56 .map(|e| normalize_text(e).to_lowercase())
57 .collect();
58 evidence.sort();
59
60 let canonical = format!(
61 "{}|{}|{}|{}|{}|{}",
62 tool,
63 normalize_text(&input.title).to_lowercase(),
64 normalize_text(&input.body).to_lowercase(),
65 normalize_text(&input.session).to_lowercase(),
66 normalize_text(&input.source).to_lowercase(),
67 evidence.join("|")
68 );
69
70 let mut h = std::collections::hash_map::DefaultHasher::new();
71 canonical.hash(&mut h);
72 format!("{:016x}", h.finish())
73}
74
75#[derive(Debug, Clone, PartialEq)]
76pub struct PersistedFeedbackReport {
77 pub id: String,
78 pub tool: String,
79 pub created_at: String,
80 pub dedupe_key: String,
81 pub input: FeedbackInput,
82}
83
84fn serialize_report(report: &PersistedFeedbackReport) -> String {
85 let mut lines: Vec<String> = vec![
86 "version=1".into(),
87 format!("id={}", report.id),
88 format!("tool={}", report.tool),
89 format!("createdAt={}", report.created_at),
90 format!("dedupeKey={}", report.dedupe_key),
91 format!("title={}", report.input.title),
92 format!("body={}", report.input.body),
93 format!("session={}", report.input.session),
94 format!("source={}", report.input.source),
95 format!("confidence={}", report.input.confidence),
96 ];
97 for (i, ev) in report.input.evidence.iter().enumerate() {
98 lines.push(format!("evidence.{i}={ev}"));
99 }
100 lines.join("\n") + "\n"
101}
102
103fn is_report_file_name(name: &str) -> bool {
104 name.starts_with("report_") && name.ends_with(".kv")
105}
106
107pub struct FeedbackReportStore {
108 reports_dir: PathBuf,
109}
110
111impl FeedbackReportStore {
112 pub fn new(reports_dir: impl Into<PathBuf>) -> Self {
113 Self {
114 reports_dir: reports_dir.into(),
115 }
116 }
117
118 pub fn persist(
119 &self,
120 tool: &str,
121 input: FeedbackInput,
122 ) -> Result<PersistedFeedbackReport, String> {
123 validate_feedback_tool_input(tool, &input)?;
124 let created_at = iso_now();
125 let dedupe_key = generate_feedback_dedupe_key(tool, &input);
126 let id = format!(
127 "report_{}_{}",
128 created_at.replace([':', '.'], "-"),
129 short_id()
130 );
131 let report = PersistedFeedbackReport {
132 id: id.clone(),
133 tool: tool.to_string(),
134 created_at,
135 dedupe_key,
136 input,
137 };
138 std::fs::create_dir_all(&self.reports_dir).map_err(|e| e.to_string())?;
139 let path = self.reports_dir.join(format!("{id}.kv"));
140 atomic_write_file(&path, &serialize_report(&report)).map_err(|e| e.to_string())?;
141 Ok(report)
142 }
143
144 pub fn list(&self) -> Vec<PersistedFeedbackReport> {
145 let Ok(rd) = std::fs::read_dir(&self.reports_dir) else {
146 return vec![];
147 };
148 let mut out = vec![];
149 for ent in rd.flatten() {
150 let name = ent.file_name().to_string_lossy().to_string();
151 if !is_report_file_name(&name) {
152 continue;
153 }
154 let Ok(content) = std::fs::read_to_string(ent.path()) else {
155 continue;
156 };
157 let Ok(report) = parse_report_file(&content) else {
158 continue;
159 };
160 out.push(report);
161 }
162 out.sort_by(|a, b| a.created_at.cmp(&b.created_at));
163 out
164 }
165}
166
167fn parse_report_file(content: &str) -> Result<PersistedFeedbackReport, String> {
168 let raw = parse_key_value_content(content);
169 let tool = raw.get("tool").cloned().unwrap_or_default();
170 if tool != FEEDBACK_TOOL_BUG_REPORT && tool != FEEDBACK_TOOL_IMPROVEMENT_SUGGESTION {
171 return Err("Invalid tool".into());
172 }
173 let mut evidence: Vec<(usize, String)> = raw
174 .iter()
175 .filter_map(|(k, v)| {
176 k.strip_prefix("evidence.")
177 .map(|idx| (idx.parse::<usize>().unwrap_or(0), v.clone()))
178 })
179 .collect();
180 evidence.sort_by_key(|(i, _)| *i);
181 let evidence = evidence.into_iter().map(|(_, v)| v).collect::<Vec<_>>();
182 let input = FeedbackInput {
183 title: raw.get("title").cloned().unwrap_or_default(),
184 body: raw.get("body").cloned().unwrap_or_default(),
185 evidence,
186 session: raw.get("session").cloned().unwrap_or_default(),
187 source: raw.get("source").cloned().unwrap_or_default(),
188 confidence: raw
189 .get("confidence")
190 .and_then(|s| s.parse::<f64>().ok())
191 .unwrap_or(f64::NAN),
192 };
193 validate_feedback_tool_input(&tool, &input)?;
194 Ok(PersistedFeedbackReport {
195 id: raw.get("id").cloned().unwrap_or_default(),
196 tool,
197 created_at: raw.get("createdAt").cloned().unwrap_or_default(),
198 dedupe_key: raw.get("dedupeKey").cloned().unwrap_or_default(),
199 input,
200 })
201}
202
203fn iso_now() -> String {
204 use std::time::{SystemTime, UNIX_EPOCH};
205 let ms = SystemTime::now()
206 .duration_since(UNIX_EPOCH)
207 .unwrap_or_default()
208 .as_millis();
209 format!("{ms}Z")
210}
211
212fn short_id() -> String {
213 use std::time::{SystemTime, UNIX_EPOCH};
214 let n = SystemTime::now()
215 .duration_since(UNIX_EPOCH)
216 .unwrap_or_default()
217 .subsec_nanos();
218 format!("{:08x}", n)
219}