use crate::config::Config;
use crate::context::cache::{FileAccess, SessionContext};
use crate::context::hash::fnv1a_64;
pub fn evaluate(
ctx: &mut SessionContext,
cmd: &str,
files: &[String],
file_access: FileAccess,
errors: &[String],
cfg: &Config,
) -> Vec<String> {
if !cfg.nudge_enabled {
return Vec::new();
}
let mut hints: Vec<String> = Vec::new();
for e in errors {
let normalized = crate::context::cache::normalize_error(e);
let fp = fnv1a_64(normalized.as_bytes());
let count = ctx.bump_error_count(fp);
if count >= cfg.nudge_error_threshold {
let key = format!("err:{:016x}", fp);
if ctx.mark_nudged(&key) {
let snippet: String = e.trim().chars().take(80).collect();
hints.push(format!(
"[squeez: hint — error seen ×{}: \"{}\" — consider documenting the fix in CLAUDE.md/AGENTS.md]",
count, snippet
));
}
}
}
let counts_write = matches!(
file_access,
FileAccess::Write | FileAccess::Created | FileAccess::Deleted
);
if counts_write {
for p in files {
let count = ctx.bump_file_mod(p);
if count >= cfg.nudge_file_mod_threshold {
let key = format!("file:{}", p);
if ctx.mark_nudged(&key) {
hints.push(format!(
"[squeez: hint — {} modified ×{} without commit — consider `git add` + `git commit`]",
p, count
));
}
}
}
}
if let Some(cmd_name) = first_token(cmd) {
if is_expensive(cmd_name) {
let count = ctx.bump_cmd_repeat(cmd_name);
if count >= cfg.nudge_cmd_repeat_threshold {
let key = format!("cmd:{}", cmd_name);
if ctx.mark_nudged(&key) {
hints.push(format!(
"[squeez: hint — `{}` run ×{} — consider scripting or caching the result]",
cmd_name, count
));
}
}
}
}
hints
}
fn first_token(cmd: &str) -> Option<&str> {
let first = cmd.split_whitespace().next()?;
Some(first.rsplit('/').next().unwrap_or(first))
}
fn is_expensive(name: &str) -> bool {
matches!(
name,
"cargo"
| "make"
| "cmake"
| "gradle"
| "mvn"
| "xcodebuild"
| "npm"
| "pnpm"
| "yarn"
| "bun"
| "jest"
| "vitest"
| "pytest"
| "playwright"
| "tsc"
| "eslint"
| "biome"
| "next"
| "terraform"
| "tofu"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> SessionContext {
SessionContext::default()
}
fn cfg() -> Config {
Config::default()
}
#[test]
fn no_nudge_below_threshold() {
let mut c = ctx();
let cfg = cfg();
let hints1 = evaluate(&mut c, "cargo build", &[], FileAccess::Read, &["error: foo".into()], &cfg);
let hints2 = evaluate(&mut c, "cargo build", &[], FileAccess::Read, &["error: foo".into()], &cfg);
assert!(hints1.is_empty());
assert!(hints2.is_empty());
}
#[test]
fn error_threshold_emits_once() {
let mut c = ctx();
let cfg = cfg();
let err = vec!["error: connection refused on tcp://10.0.0.1:5432".to_string()];
evaluate(&mut c, "cmd", &[], FileAccess::Read, &err, &cfg);
evaluate(&mut c, "cmd", &[], FileAccess::Read, &err, &cfg);
let h3 = evaluate(&mut c, "cmd", &[], FileAccess::Read, &err, &cfg);
let h4 = evaluate(&mut c, "cmd", &[], FileAccess::Read, &err, &cfg);
assert_eq!(h3.len(), 1);
assert!(h3[0].contains("seen ×3"));
assert!(h4.is_empty());
}
#[test]
fn file_mod_threshold_counts_writes_only() {
let mut c = ctx();
let cfg = cfg();
let files = vec!["/foo.rs".to_string()];
for _ in 0..5 {
let h = evaluate(&mut c, "cmd", &files, FileAccess::Read, &[], &cfg);
assert!(h.is_empty());
}
for i in 1..=5 {
let h = evaluate(&mut c, "cmd", &files, FileAccess::Write, &[], &cfg);
if i < 5 {
assert!(h.is_empty(), "early nudge at i={}", i);
} else {
assert_eq!(h.len(), 1);
assert!(h[0].contains("/foo.rs"));
assert!(h[0].contains("×5"));
}
}
}
#[test]
fn cmd_repeat_threshold_only_for_expensive_commands() {
let mut c = ctx();
let cfg = cfg();
for _ in 0..10 {
let h = evaluate(&mut c, "ls -la", &[], FileAccess::Read, &[], &cfg);
assert!(h.is_empty());
}
for i in 1..=4 {
let h = evaluate(&mut c, "cargo test", &[], FileAccess::Read, &[], &cfg);
if i < 4 {
assert!(h.is_empty());
} else {
assert_eq!(h.len(), 1);
assert!(h[0].contains("cargo"));
}
}
}
#[test]
fn nudge_disabled_short_circuits() {
let mut c = ctx();
let mut cfg = cfg();
cfg.nudge_enabled = false;
for _ in 0..10 {
let h = evaluate(
&mut c,
"cargo build",
&["/x.rs".into()],
FileAccess::Write,
&["error: boom".into()],
&cfg,
);
assert!(h.is_empty());
}
}
#[test]
fn path_prefix_stripped_in_cmd_name() {
assert_eq!(first_token("/usr/local/bin/cargo test"), Some("cargo"));
assert_eq!(first_token("cargo"), Some("cargo"));
assert_eq!(first_token(""), None);
}
#[test]
fn error_fingerprint_collapses_paths_and_digits() {
let mut c = ctx();
let cfg = cfg();
evaluate(
&mut c,
"cmd",
&[],
FileAccess::Read,
&["error: file /tmp/a.txt line 42 failed".into()],
&cfg,
);
evaluate(
&mut c,
"cmd",
&[],
FileAccess::Read,
&["error: file /var/log/b.txt line 99 failed".into()],
&cfg,
);
assert_eq!(c.error_count_fp.len(), 1);
assert_eq!(c.error_count_n[0], 2);
}
}