1use std::collections::BTreeMap;
46use std::io::Write;
47use std::path::{Path, PathBuf};
48
49use serde::{Deserialize, Serialize};
50
51pub const DEFAULT_CAP_BYTES: u64 = 512 * 1024;
56
57const RECENT_WINDOW_SECS: u64 = 24 * 60 * 60;
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct FrictionEntry {
65 pub ts: u64,
67 pub surface: String,
69 pub verb: String,
72 pub code: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub reason: Option<String>,
80}
81
82pub fn closed_reason(code: &str, details: Option<&serde_json::Value>) -> Option<&'static str> {
96 let vocab: &[&'static str] = match code {
97 "INVALID_TITLE" => &["invalid_chars", "control_chars", "id_too_long", "empty"],
98 "MEM_PATH_NOT_ALLOWED" => &["no_allowlist_configured", "no_match", "outside_workspace"],
99 _ => return None,
100 };
101 let candidate = details?.get("reason")?.as_str()?;
102 vocab.iter().find(|v| **v == candidate).copied()
103}
104
105#[derive(Debug, Clone)]
108pub struct FrictionLedger {
109 path: PathBuf,
110 cap_bytes: u64,
111}
112
113fn friction_dir(workspace_root: &Path) -> PathBuf {
116 workspace_root
117 .join(crate::workspace_store::WORKSPACE_STORE_DIR)
118 .join("state")
119 .join("friction")
120}
121
122pub fn friction_ledger_path(workspace_root: &Path) -> PathBuf {
124 friction_dir(workspace_root).join("refusals.jsonl")
125}
126
127impl FrictionLedger {
128 pub fn for_workspace(workspace_root: &Path) -> Self {
130 Self {
131 path: friction_ledger_path(workspace_root),
132 cap_bytes: DEFAULT_CAP_BYTES,
133 }
134 }
135
136 pub fn at_path(path: PathBuf, cap_bytes: u64) -> Self {
139 Self { path, cap_bytes }
140 }
141
142 pub fn record(&self, surface: &str, verb: &str, code: &str, reason: Option<&'static str>) {
151 let ts = std::time::SystemTime::now()
152 .duration_since(std::time::UNIX_EPOCH)
153 .map(|d| d.as_secs())
154 .unwrap_or_default();
155 let entry = FrictionEntry {
156 ts,
157 surface: surface.to_string(),
158 verb: verb.to_string(),
159 code: code.to_string(),
160 reason: reason.map(str::to_string),
161 };
162 let Ok(mut line) = serde_json::to_vec(&entry) else {
163 return;
164 };
165 line.push(b'\n');
166
167 let Some(dir) = self.path.parent() else {
168 return;
169 };
170 if std::fs::create_dir_all(dir).is_err() {
171 return;
172 }
173 let gitignore = dir.join(".gitignore");
177 if !gitignore.exists() {
178 let _ = std::fs::write(&gitignore, "*\n");
179 }
180
181 if let Ok(meta) = std::fs::metadata(&self.path)
186 && meta.len() >= self.cap_bytes
187 {
188 let _ = std::fs::rename(&self.path, self.rotated_path());
189 }
190
191 let Ok(mut file) = std::fs::OpenOptions::new()
194 .append(true)
195 .create(true)
196 .open(&self.path)
197 else {
198 return;
199 };
200 let _ = file.write_all(&line);
201 }
202
203 fn rotated_path(&self) -> PathBuf {
205 let mut name = self
206 .path
207 .file_name()
208 .map(|n| n.to_os_string())
209 .unwrap_or_default();
210 name.push(".1");
211 self.path.with_file_name(name)
212 }
213
214 pub fn total_bytes(&self) -> u64 {
217 let len = |p: &Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
218 len(&self.path) + len(&self.rotated_path())
219 }
220
221 pub fn entries(&self) -> Vec<FrictionEntry> {
225 let mut out = Vec::new();
226 for p in [self.rotated_path(), self.path.clone()] {
227 if let Ok(content) = std::fs::read_to_string(&p) {
228 for l in content.lines() {
229 if let Ok(e) = serde_json::from_str::<FrictionEntry>(l) {
230 out.push(e);
231 }
232 }
233 }
234 }
235 out
236 }
237
238 pub fn summarize(&self) -> serde_json::Value {
243 let entries = self.entries();
244 let now = std::time::SystemTime::now()
245 .duration_since(std::time::UNIX_EPOCH)
246 .map(|d| d.as_secs())
247 .unwrap_or_default();
248 let cutoff = now.saturating_sub(RECENT_WINDOW_SECS);
249
250 let mut by_code: BTreeMap<String, u64> = BTreeMap::new();
251 let mut by_verb: BTreeMap<String, u64> = BTreeMap::new();
252 let mut by_reason: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
256 let mut recent_by_code: BTreeMap<String, u64> = BTreeMap::new();
257 let mut recent_total = 0u64;
258 for e in &entries {
259 *by_code.entry(e.code.clone()).or_default() += 1;
260 *by_verb
261 .entry(format!("{}:{}", e.surface, e.verb))
262 .or_default() += 1;
263 if let Some(reason) = &e.reason {
264 *by_reason
265 .entry(e.code.clone())
266 .or_default()
267 .entry(reason.clone())
268 .or_default() += 1;
269 }
270 if e.ts >= cutoff {
271 recent_total += 1;
272 *recent_by_code.entry(e.code.clone()).or_default() += 1;
273 }
274 }
275 serde_json::json!({
276 "total": entries.len(),
277 "by_code": by_code,
278 "by_verb": by_verb,
279 "by_reason": by_reason,
280 "recent_24h": {
281 "total": recent_total,
282 "by_code": recent_by_code,
283 },
284 "ledger_bytes": self.total_bytes(),
285 })
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use tempfile::TempDir;
293
294 #[test]
295 fn record_appends_and_summarize_counts() {
296 let tmp = TempDir::new().unwrap();
297 let ledger = FrictionLedger::for_workspace(tmp.path());
298 ledger.record("cli", "create", "UNKNOWN_SECTION", None);
299 ledger.record("mcp", "memstead_create", "UNKNOWN_SECTION", None);
300 ledger.record("cli", "relate", "INVALID_REL_TYPE", None);
301 let s = ledger.summarize();
302 assert_eq!(s["total"], 3);
303 assert_eq!(s["by_code"]["UNKNOWN_SECTION"], 2);
304 assert_eq!(s["by_code"]["INVALID_REL_TYPE"], 1);
305 assert_eq!(s["by_verb"]["cli:create"], 1);
306 assert_eq!(s["by_verb"]["mcp:memstead_create"], 1);
307 assert_eq!(s["recent_24h"]["total"], 3);
308 }
309
310 #[test]
314 fn size_bound_holds_under_refusal_loop() {
315 let tmp = TempDir::new().unwrap();
316 let cap = 2048u64;
317 let ledger = FrictionLedger::at_path(tmp.path().join("refusals.jsonl"), cap);
318 for i in 0..2000 {
319 ledger.record("cli", "create", &format!("CODE_{}", i % 7), None);
320 }
321 let total = ledger.total_bytes();
322 assert!(
323 total <= 2 * cap + 256,
324 "ledger grew past its bound: {total} bytes (cap {cap})"
325 );
326 let s = ledger.summarize();
328 assert!(s["total"].as_u64().unwrap() > 0);
329 }
330
331 #[test]
333 fn ledger_dir_is_self_ignoring() {
334 let tmp = TempDir::new().unwrap();
335 let ledger = FrictionLedger::for_workspace(tmp.path());
336 ledger.record("cli", "create", "UNKNOWN_SECTION", None);
337 let gitignore = friction_dir(tmp.path()).join(".gitignore");
338 assert_eq!(std::fs::read_to_string(gitignore).unwrap(), "*\n");
339 }
340
341 #[test]
344 #[cfg(unix)]
345 fn unwritable_dir_degrades_to_not_recording() {
346 use std::os::unix::fs::PermissionsExt;
347 let tmp = TempDir::new().unwrap();
348 let sealed = tmp.path().join("sealed");
349 std::fs::create_dir_all(&sealed).unwrap();
350 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o555)).unwrap();
351 let ledger = FrictionLedger::at_path(sealed.join("sub").join("refusals.jsonl"), 1024);
352 ledger.record("cli", "create", "UNKNOWN_SECTION", None);
353 assert_eq!(ledger.entries().len(), 0);
354 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
355 }
356
357 #[test]
362 fn concurrent_appends_never_tear_lines() {
363 let tmp = TempDir::new().unwrap();
364 let path = tmp.path().join("refusals.jsonl");
365 let per_thread = 200;
366 let threads: Vec<_> = (0..4)
367 .map(|t| {
368 let ledger = FrictionLedger::at_path(path.clone(), u64::MAX);
369 std::thread::spawn(move || {
370 for i in 0..per_thread {
371 ledger.record("mcp", &format!("verb_{t}"), &format!("CODE_{i}"), None);
372 }
373 })
374 })
375 .collect();
376 for t in threads {
377 t.join().unwrap();
378 }
379 let content = std::fs::read_to_string(&path).unwrap();
380 let mut parsed = 0;
381 for line in content.lines() {
382 serde_json::from_str::<FrictionEntry>(line)
383 .unwrap_or_else(|e| panic!("torn/merged ledger line: {e}: {line:?}"));
384 parsed += 1;
385 }
386 assert_eq!(parsed, 4 * per_thread, "no entry lost or merged");
387 }
388
389 #[test]
393 fn reasons_recorded_and_summarized_for_closed_vocab_codes() {
394 let tmp = TempDir::new().unwrap();
395 let ledger = FrictionLedger::for_workspace(tmp.path());
396 let title_details = serde_json::json!({ "reason": "invalid_chars", "input": "x" });
397 let path_details = serde_json::json!({ "reason": "no_match", "candidate": "y" });
398 ledger.record(
399 "cli",
400 "create",
401 "INVALID_TITLE",
402 closed_reason("INVALID_TITLE", Some(&title_details)),
403 );
404 ledger.record(
405 "cli",
406 "create",
407 "INVALID_TITLE",
408 closed_reason("INVALID_TITLE", Some(&title_details)),
409 );
410 ledger.record(
411 "mcp",
412 "memstead_mem_create",
413 "MEM_PATH_NOT_ALLOWED",
414 closed_reason("MEM_PATH_NOT_ALLOWED", Some(&path_details)),
415 );
416 ledger.record("cli", "update", "UNKNOWN_SECTION", None);
417
418 let s = ledger.summarize();
419 assert_eq!(s["by_reason"]["INVALID_TITLE"]["invalid_chars"], 2);
420 assert_eq!(s["by_reason"]["MEM_PATH_NOT_ALLOWED"]["no_match"], 1);
421 assert!(s["by_reason"].get("UNKNOWN_SECTION").is_none());
423 assert_eq!(s["by_code"]["UNKNOWN_SECTION"], 1);
424 }
425
426 #[test]
429 fn entry_without_reason_omits_the_field() {
430 let tmp = TempDir::new().unwrap();
431 let path = tmp.path().join("refusals.jsonl");
432 let ledger = FrictionLedger::at_path(path.clone(), u64::MAX);
433 ledger.record("cli", "create", "UNKNOWN_SECTION", None);
434 let content = std::fs::read_to_string(&path).unwrap();
435 assert!(
436 !content.contains("reason"),
437 "reason key must be absent, got: {content}"
438 );
439 }
440
441 #[test]
446 fn closed_reason_rejects_unlisted_values_and_codes() {
447 let attacker = serde_json::json!({ "reason": "caller-supplied /etc/passwd" });
448 assert_eq!(closed_reason("INVALID_TITLE", Some(&attacker)), None);
449 let open_ended = serde_json::json!({ "reason": "must not carry a version or range" });
452 assert_eq!(closed_reason("CONFIG_ERROR", Some(&open_ended)), None);
453 assert_eq!(closed_reason("INVALID_TITLE", None), None);
454 let ok = serde_json::json!({ "reason": "id_too_long" });
457 let got: Option<&'static str> = closed_reason("INVALID_TITLE", Some(&ok));
458 assert_eq!(got, Some("id_too_long"));
459 }
460
461 #[test]
465 fn pre_change_entries_parse_and_count_across_generations() {
466 let tmp = TempDir::new().unwrap();
467 let path = tmp.path().join("refusals.jsonl");
468 std::fs::write(
470 tmp.path().join("refusals.jsonl.1"),
471 "{\"ts\":100,\"surface\":\"cli\",\"verb\":\"mem\",\"code\":\"MEM_PATH_NOT_ALLOWED\"}\n",
472 )
473 .unwrap();
474 std::fs::write(
476 &path,
477 "{\"ts\":200,\"surface\":\"cli\",\"verb\":\"create\",\"code\":\"INVALID_TITLE\"}\n",
478 )
479 .unwrap();
480 let ledger = FrictionLedger::at_path(path, u64::MAX);
481 ledger.record("cli", "create", "INVALID_TITLE", Some("empty"));
482
483 let entries = ledger.entries();
484 assert_eq!(entries.len(), 3);
485 assert_eq!(entries[0].reason, None);
486 assert_eq!(entries[1].reason, None);
487 assert_eq!(entries[2].reason.as_deref(), Some("empty"));
488 let s = ledger.summarize();
489 assert_eq!(s["total"], 3);
490 assert_eq!(s["by_code"]["INVALID_TITLE"], 2);
491 assert_eq!(s["by_code"]["MEM_PATH_NOT_ALLOWED"], 1);
492 assert_eq!(s["by_reason"]["INVALID_TITLE"]["empty"], 1);
493 }
494}