use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use clap::Subcommand;
use recall_hooks::exit;
use serde::{Deserialize, Serialize};
use crate::project as proj;
use crate::ui;
mod apply;
mod claude;
mod reports;
mod sources;
#[derive(Subcommand)]
pub enum Cmd {
#[command(verbatim_doc_comment)]
Run {
#[arg(verbatim_doc_comment)]
files: Vec<String>,
#[arg(long, verbatim_doc_comment)]
all: bool,
#[arg(long, verbatim_doc_comment)]
claude: bool,
#[arg(long, value_name = "N", default_value_t = claude::DEFAULT_MAX_CALLS, requires = "claude")]
max_calls: u32,
#[arg(long, verbatim_doc_comment)]
probe_hosts: bool,
#[arg(long)]
json: bool,
},
Show {
#[arg(long)]
json: bool,
},
#[command(verbatim_doc_comment)]
Apply {
claim: String,
#[arg(long, short)]
yes: bool,
},
}
pub async fn run(cmd: Cmd) -> anyhow::Result<i32> {
match cmd {
Cmd::Run {
files,
all,
claude,
max_calls,
probe_hosts,
json,
} => {
let layer3 = claude.then_some(max_calls);
run_review(&files, all, layer3, probe_hosts, json).await
}
Cmd::Show { json } => show_last(json),
Cmd::Apply { claim, yes } => apply::run(&claim, yes).await,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Class {
Present,
Record,
Rule,
Unsure,
Conflict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Stale,
StillTrue,
CantTell,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Evidence {
pub source: String,
pub detail: String,
pub verdict: Verdict,
#[serde(skip)]
pub citable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Claim {
pub id: String,
pub file: String,
pub lines: [u32; 2],
pub class: Class,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub verdict: Option<Verdict>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<u8>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<Evidence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_edit: Option<recall_wire::SuggestedEdit>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub eval: Vec<reports::EvalFinding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnavailableSource {
pub source: String,
pub reason: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SourcesRead {
#[serde(skip_serializing_if = "Option::is_none")]
pub repository_head: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_commit: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compose_files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub probed: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unavailable: Vec<UnavailableSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude: Option<ClaudeRun>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evaluation: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ClaudeRun {
pub calls: u32,
pub reused: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skipped: Vec<SkippedFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkippedFile {
pub file: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Report {
pub reviewed_at: String,
pub evidence: SourcesRead,
pub claims: Vec<Claim>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct SavedState {
#[serde(default)]
files: BTreeMap<String, FileState>,
#[serde(default)]
report: Option<Report>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct FileState {
content_sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
claude: Option<claude::Reviewed>,
}
fn load_state(path: &Path) -> SavedState {
std::fs::read(path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
fn save_state(path: &Path, state: &SavedState) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_vec_pretty(state).map_err(std::io::Error::other)?;
std::fs::write(path, body)
}
fn now_rfc3339() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default()
}
fn join_relative(dir: &Path, rel: &str) -> PathBuf {
let mut p = dir.to_path_buf();
for part in rel.split('/').filter(|s| !s.is_empty()) {
p.push(part);
}
p
}
fn is_project_scope(scopes: &[recall_hooks::Scope], rel: &str) -> bool {
recall_hooks::scope::route(scopes, rel)
.map(|(scope, _)| scope.prefix.is_none())
.unwrap_or(true)
}
async fn run_review(
file_args: &[String],
all: bool,
layer3: Option<u32>,
probe_hosts: bool,
json: bool,
) -> anyhow::Result<i32> {
let here = proj::resolve();
let cfg = here.config();
let remote = proj::remote();
let scopes = recall_hooks::scope::scopes(
here.project_key(&cfg, &remote),
cfg.global_key.clone(),
cfg.machine_key.clone(),
);
let memory_dir = here.memory_dir();
let review_file = here.review_file();
let all_files: Vec<String> = recall_hooks::state::list_memory_files(&memory_dir)
.unwrap_or_default()
.into_iter()
.filter(|rel| rel != "MEMORY.md")
.filter(|rel| recall_hooks::scope::route(&scopes, rel).is_some())
.collect();
let mut candidates = all_files.clone();
if !file_args.is_empty() {
let wanted: HashSet<&str> = file_args.iter().map(String::as_str).collect();
let missing: Vec<&str> = wanted
.iter()
.copied()
.filter(|f| !all_files.iter().any(|c| c == f))
.collect();
if !missing.is_empty() {
let mut missing = missing;
missing.sort_unstable();
eprintln!(
"recall review: not a memory file in any scope that is on: {}",
missing.join(", ")
);
return Ok(exit::CONFIG);
}
candidates.retain(|f| wanted.contains(f.as_str()));
}
candidates.sort();
let repo = Repo::at(proj::git_root());
let newest_tag = repo.newest_tag();
let mut extracted = Vec::new();
let mut new_file_states = BTreeMap::new();
let mut contents: BTreeMap<String, (String, bool)> = BTreeMap::new();
for rel in &candidates {
let is_project = is_project_scope(&scopes, rel);
let path = join_relative(&memory_dir, rel);
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
new_file_states.insert(
rel.clone(),
FileState {
content_sha256: recall_wire::content_sha256(&content),
claude: None,
},
);
let note_type = front_matter_type(&content);
for raw in extract_claims(&content) {
let class = classify(&raw.text, raw.in_record_section, note_type.as_deref());
extracted.push((rel.clone(), is_project, raw, class));
}
contents.insert(rel.clone(), (content, is_project));
}
let mut facts = sources::read(&here, &cfg, repo.root.as_deref()).await;
let to_probe = sources::hosts_to_probe(
extracted
.iter()
.map(|(_, _, raw, class)| (*class, raw.text.as_str())),
&facts,
probe_hosts,
);
facts.probes = sources::probe(&to_probe).await;
let mut new_claims = Vec::new();
for (rel, is_project, raw, class) in extracted {
let (verdict, layer, evidence) =
judge(class, &raw.text, &repo, is_project, &newest_tag, &facts);
new_claims.push(Claim {
id: String::new(),
file: rel,
lines: raw.lines,
class,
text: raw.text,
verdict,
layer,
evidence,
suggested_edit: None,
eval: Vec::new(),
});
}
let mut saved = load_state(&review_file);
let layer3_run = layer_three(
&mut new_claims,
&mut new_file_states,
&saved,
&contents,
Layer3Inputs {
repo: &repo,
newest_tag: &newest_tag,
facts: &facts,
all,
max_calls: layer3,
scopes: &scopes,
},
)
.await;
let processed: HashSet<&str> = candidates.iter().map(String::as_str).collect();
let mut claims: Vec<Claim> = saved
.report
.as_ref()
.map(|r| r.claims.clone())
.unwrap_or_default()
.into_iter()
.filter(|c| !processed.contains(c.file.as_str()))
.collect();
claims.extend(new_claims);
claims.sort_by(|a, b| a.file.cmp(&b.file).then(a.lines[0].cmp(&b.lines[0])));
for (i, c) in claims.iter_mut().enumerate() {
c.id = format!("t{}", i + 1);
}
for (rel, state) in new_file_states {
saved.files.insert(rel, state);
}
let read = if facts.server.answered {
reports::newest(&cfg).await
} else {
reports::Read::NoServer
};
for c in claims.iter_mut() {
c.eval.clear();
}
if let reports::Read::Newest(id, findings) = &read {
reports::beside(&mut claims, id, findings, |file| {
recall_hooks::scope::route(&scopes, file).map(|(s, path)| (s.key.clone(), path))
});
}
let mut unavailable = Vec::new();
let mut missing = |source: &str, reason: String| {
unavailable.push(UnavailableSource {
source: source.to_string(),
reason,
})
};
if repo.root.is_none() {
missing("repository", "this is not a git repository".to_string());
}
match (&facts.server.url, facts.server.answered) {
(None, _) => missing(
"server",
"no server is configured on this machine".to_string(),
),
(Some(url), false) => missing(
"server",
format!(
"/health at {url} did not answer ({})",
facts.server.error.as_deref().unwrap_or("no reason given")
),
),
(Some(_), true) => {}
}
if facts.compose.is_none() {
missing(
"compose",
"the checkout has no deploy/docker-compose*.yml that reads as YAML".to_string(),
);
}
if !probe_hosts {
let named = sources::hosts_to_probe(
claims.iter().map(|c| (c.class, c.text.as_str())),
&facts,
true,
)
.len();
if named > 0 {
missing(
"probe",
format!(
"{named} host(s) named in memory were not asked anything; --probe-hosts asks \
them"
),
);
}
}
if let reports::Read::Unreadable(why) = &read {
missing("evaluation", why.clone());
}
if layer3.is_none() && layer3_run.waiting > 0 {
missing(
"claude",
format!(
"{} file(s) hold claims only layer 3 can decide; --claude asks this machine's \
claude CLI about them",
layer3_run.waiting
),
);
}
let report = Report {
reviewed_at: now_rfc3339(),
evidence: SourcesRead {
repository_head: repo.head_short(),
server_version: facts.server.version.clone(),
server_commit: facts.server.commit.clone(),
compose_files: facts
.compose
.as_ref()
.map(|c| c.files.clone())
.unwrap_or_default(),
probed: facts.probes.keys().cloned().collect(),
unavailable,
claude: layer3.map(|_| layer3_run.run),
evaluation: match read {
reports::Read::Newest(id, _) => Some(id),
_ => None,
},
},
claims,
};
saved.report = Some(report);
if let Err(e) = save_state(&review_file, &saved) {
eprintln!(
"recall review: this report was not saved ({e}); recall review show will not have \
it until the next run succeeds"
);
}
let report = saved.report.as_ref().expect("just set");
if json {
println!("{}", serde_json::to_string_pretty(report)?);
} else {
print_text(report);
}
Ok(exit::OK)
}
struct Layer3Inputs<'a> {
repo: &'a Repo,
newest_tag: &'a Option<(u64, u64, u64, String)>,
facts: &'a sources::Facts,
all: bool,
max_calls: Option<u32>,
scopes: &'a [recall_hooks::Scope],
}
struct Layer3Done {
run: ClaudeRun,
waiting: usize,
}
struct ToAsk<'a> {
rel: &'a String,
asked: Vec<claude::Asked>,
facts: Vec<String>,
context: Vec<String>,
fallback: Option<claude::Reviewed>,
kept: Option<claude::Reviewed>,
}
fn undecided(c: &Claim) -> bool {
c.class == Class::Unsure
|| (c.class == Class::Present && matches!(c.verdict, None | Some(Verdict::CantTell)))
}
fn fact_sheet(
(global, global_context): (&[String], &[String]),
claims: &[Claim],
rel: &str,
inputs: &Layer3Inputs<'_>,
is_project: bool,
) -> (Vec<String>, Vec<String>) {
let mut facts = global.to_vec();
let mut context = global_context.to_vec();
for c in claims.iter().filter(|c| c.file == rel) {
if c.class == Class::Unsure {
for a in anchors(&c.text) {
let o = evidence_for(
&a,
inputs.repo,
is_project,
inputs.newest_tag,
&c.text,
inputs.facts,
);
match o.signal {
Signal::Unknown => context.push(o.detail),
_ => facts.push(o.detail),
}
}
} else {
for e in c.evidence.iter().filter(|e| e.source != claude::SOURCE) {
if e.citable {
facts.push(e.detail.clone());
} else {
context.push(e.detail.clone());
}
}
}
}
let facts = claude::dedup(facts);
let context = claude::dedup(context)
.into_iter()
.filter(|c| !facts.contains(c))
.collect();
(facts, context)
}
struct EditBase<'a> {
content: &'a str,
target: Option<(String, String)>,
}
impl<'a> EditBase<'a> {
fn of(rel: &str, content: &'a str, scopes: &[recall_hooks::Scope]) -> Self {
EditBase {
content,
target: recall_hooks::scope::route(scopes, rel)
.map(|(scope, path)| (scope.key.clone(), path)),
}
}
}
fn apply_decided(
claims: &mut [Claim],
rel: &str,
decided: &[claude::Decided],
base: &EditBase<'_>,
) {
for d in decided.iter().map(claude::Decided::checked) {
if let Some(c) = claims
.iter_mut()
.find(|c| c.file == rel && c.lines == d.lines && c.text == d.text && undecided(c))
{
c.class = d.class;
c.verdict = d.verdict;
c.layer = Some(3);
c.evidence.push(d.evidence);
c.suggested_edit = match (&d.rewrite, &base.target) {
(Some(rewrite), Some((key, path))) => {
apply::build(base.content, c.lines, &c.text, (key, path), rewrite).ok()
}
_ => None,
};
}
}
}
async fn layer_three(
claims: &mut [Claim],
states: &mut BTreeMap<String, FileState>,
saved: &SavedState,
contents: &BTreeMap<String, (String, bool)>,
inputs: Layer3Inputs<'_>,
) -> Layer3Done {
let mut global = Vec::new();
if let Some(head) = inputs.repo.head_short() {
global.push(format!("The project's repository is at commit {head}"));
}
if let Some((_, _, _, tag)) = inputs.newest_tag {
global.push(format!(
"The newest release tag in the project's repository is {tag}"
));
}
let (server_facts, global_context) = inputs.facts.sheet();
global.extend(server_facts);
let asking = inputs.max_calls.is_some();
let mut run = ClaudeRun::default();
let mut to_ask: Vec<ToAsk<'_>> = Vec::new();
for (rel, (_, is_project)) in contents {
let asked: Vec<claude::Asked> = claims
.iter()
.filter(|c| &c.file == rel && undecided(c))
.map(|c| claude::Asked {
lines: c.lines,
text: c.text.clone(),
class: c.class,
})
.collect();
if asked.is_empty() {
continue;
}
let (facts, context) = fact_sheet(
(&global, &global_context),
claims,
rel,
&inputs,
*is_project,
);
let sha = states[rel].content_sha256.clone();
let stored = saved
.files
.get(rel)
.and_then(|f| f.claude.clone())
.filter(|r| r.content_sha256 == sha);
let (fallback, kept) = match stored {
Some(r) if r.holds(&sha, &facts) => {
if r.complete && !(inputs.all && asking) {
apply_decided(
claims,
rel,
&r.decided,
&EditBase::of(rel, &contents[rel].0, inputs.scopes),
);
states.get_mut(rel).expect("read this run").claude = Some(r);
run.reused += 1;
continue;
}
(Some(r), None)
}
other => (None, other),
};
to_ask.push(ToAsk {
rel,
asked,
facts,
context,
fallback,
kept,
});
}
let waiting = to_ask.len();
to_ask.sort_by_cached_key(|t| {
t.fallback
.as_ref()
.or(t.kept.as_ref())
.map(|r| r.reviewed_at.clone())
});
let mut cannot_run: Option<String> = None;
let merger = claude::merger();
if asking && !to_ask.is_empty() {
let status = merger.check_status().await;
if !status.available {
cannot_run = Some(format!(
"claude is not installed here ({}); install Claude Code, or leave out --claude",
status.error
));
} else if !status.logged_in && status.error.is_empty() {
cannot_run = Some(
"claude is not logged in on this machine; run claude and log in, then try again"
.to_string(),
);
}
}
let redactor = asking.then(|| {
let files: Vec<recall_wire::EvaluateFile> = contents
.iter()
.map(|(rel, (content, _))| recall_wire::EvaluateFile {
file_path: rel.clone(),
content: content.clone(),
..Default::default()
})
.collect();
recall_worker::Redactor::new(&files)
});
for t in to_ask {
let rel = t.rel;
let answer = match (inputs.max_calls, &redactor) {
(Some(max_calls), Some(redactor)) => {
let (content, is_project) = &contents[rel];
if let Some(why) = &cannot_run {
Err(why.clone())
} else if run.calls >= max_calls {
Err(format!(
"--max-calls {max_calls} reached; a later run asks about it"
))
} else {
let scope = if *is_project {
"project scope: about this repository"
} else {
"global or machine scope: not about this repository"
};
let prompt = claude::prompt(
rel,
scope,
content,
(&t.facts, &t.context),
&t.asked,
redactor,
);
if prompt.len() > claude::MAX_PROMPT_BYTES {
Err(format!(
"its prompt is {} bytes, more than one call is handed ({})",
prompt.len(),
claude::MAX_PROMPT_BYTES
))
} else {
eprintln!(
"recall review: asking claude about {}",
sanitize_for_terminal(rel)
);
match merger.ask(claude::SYSTEM_PROMPT, &prompt).await {
Ok(text) => {
run.calls += 1;
claude::decide(&text, &t.asked, &t.facts)
.map(|decided| {
decided
.into_iter()
.map(|mut d| {
if redactor.text(&d.text) != d.text {
d.rewrite = None;
}
d
})
.collect::<Vec<_>>()
})
.ok_or_else(|| {
"claude's answer was not the JSON layer 3 asks for"
.to_string()
})
}
Err(recall_worker::merge::Error::Unavailable(why)) => {
let why = format!("claude cannot run on this machine: {why}");
cannot_run = Some(why.clone());
Err(why)
}
Err(e) => {
run.calls += 1;
let why = e.to_string();
cannot_run =
Some(format!("not asked, after claude failed on {rel}"));
Err(why)
}
}
}
}
}
_ => Err(String::new()),
};
let state = states.get_mut(rel).expect("read this run");
match answer {
Ok(decided) => {
apply_decided(
claims,
rel,
&decided,
&EditBase::of(rel, &contents[rel].0, inputs.scopes),
);
state.claude = Some(claude::Reviewed {
content_sha256: state.content_sha256.clone(),
complete: decided.len() == t.asked.len(),
decided,
reviewed_at: now_rfc3339(),
});
}
Err(reason) => {
if !reason.is_empty() {
run.skipped.push(SkippedFile {
file: rel.clone(),
reason,
});
}
if let Some(r) = t.fallback {
apply_decided(
claims,
rel,
&r.decided,
&EditBase::of(rel, &contents[rel].0, inputs.scopes),
);
state.claude = Some(r);
} else {
state.claude = t.kept;
}
}
}
}
Layer3Done { run, waiting }
}
fn show_last(json: bool) -> anyhow::Result<i32> {
let here = proj::resolve();
let saved = load_state(&here.review_file());
let Some(report) = saved.report else {
if json {
println!("null");
} else {
println!("No review yet. recall review run makes one.");
}
return Ok(exit::OK);
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_text(&report);
}
Ok(exit::OK)
}
fn judge(
class: Class,
text: &str,
repo: &Repo,
is_project: bool,
newest_tag: &Option<(u64, u64, u64, String)>,
facts: &sources::Facts,
) -> (Option<Verdict>, Option<u8>, Vec<Evidence>) {
if !matches!(class, Class::Present | Class::Rule) {
return (None, None, Vec::new());
}
let found = anchors(text);
if found.is_empty() {
return (None, None, Vec::new());
}
let observed: Vec<Observed> = found
.iter()
.map(|a| evidence_for(a, repo, is_project, newest_tag, text, facts))
.collect();
let evidence: Vec<Evidence> = observed
.iter()
.map(|o| Evidence {
source: o.source.clone(),
detail: o.detail.clone(),
verdict: verdict_of_signal(o.signal, class),
citable: o.signal != Signal::Unknown,
})
.collect();
let verdict = match class {
Class::Present => verdict_from(&observed),
_ => None,
};
(verdict, Some(2), evidence)
}
struct RawClaim {
lines: [u32; 2],
text: String,
in_record_section: bool,
}
fn front_matter_end(lines: &[&str]) -> usize {
if lines.first().map(|l| l.trim()) != Some("---") {
return 0;
}
for (i, line) in lines.iter().enumerate().skip(1) {
if line.trim() == "---" {
return i + 1;
}
}
0
}
fn front_matter_type(content: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
let end = front_matter_end(&lines);
if end == 0 {
return None;
}
for line in &lines[..end] {
if let Some(value) = line.trim().strip_prefix("type:") {
return Some(value.trim().trim_matches(['"', '\'']).to_string());
}
}
None
}
fn is_list_marker(t: &str) -> bool {
if t.starts_with("- ") || t.starts_with("* ") || t.starts_with("+ ") {
return true;
}
let digits: String = t.chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
return false;
}
let rest = &t[digits.len()..];
rest.starts_with(". ") || rest.starts_with(") ")
}
fn is_block_boundary(t: &str) -> bool {
t.is_empty() || t.starts_with('#') || t.starts_with("```") || t.starts_with("~~~")
}
fn is_history_heading(heading: &str) -> bool {
let lower = heading.to_ascii_lowercase();
["history", "previously", "correction", "corrections"]
.iter()
.any(|w| lower.contains(w))
}
fn heading_level(trimmed: &str) -> usize {
trimmed.chars().take_while(|&c| c == '#').count()
}
fn split_sentences(lines: &[&str], first_line: u32, in_record_section: bool) -> Vec<RawClaim> {
let mut joined = String::new();
let mut line_of: Vec<u32> = Vec::new();
for (idx, l) in lines.iter().enumerate() {
if idx > 0 {
joined.push(' ');
line_of.push(first_line + idx as u32);
}
for ch in l.chars() {
joined.push(ch);
line_of.push(first_line + idx as u32);
}
}
let chars: Vec<char> = joined.chars().collect();
let mut out = Vec::new();
let mut start = 0usize;
let mut i = 0usize;
while i < chars.len() {
let c = chars[i];
let at_boundary =
(c == '.' || c == '!' || c == '?') && (i + 1 == chars.len() || chars[i + 1] == ' ');
if at_boundary {
push_sentence(&chars, &line_of, start, i, in_record_section, &mut out);
start = (i + 2).min(chars.len());
i = start;
continue;
}
i += 1;
}
if start < chars.len() {
push_sentence(
&chars,
&line_of,
start,
chars.len() - 1,
in_record_section,
&mut out,
);
}
out
}
fn push_sentence(
chars: &[char],
line_of: &[u32],
start: usize,
end: usize,
in_record_section: bool,
out: &mut Vec<RawClaim>,
) {
if start > end || start >= chars.len() {
return;
}
let end = end.min(chars.len() - 1);
let text: String = chars[start..=end]
.iter()
.collect::<String>()
.trim()
.to_string();
if text.is_empty() {
return;
}
let l1 = line_of[start];
let l2 = line_of[end];
out.push(RawClaim {
lines: [l1, l2],
text,
in_record_section,
});
}
fn extract_claims(content: &str) -> Vec<RawClaim> {
let lines: Vec<&str> = content.lines().collect();
let mut out = Vec::new();
let mut i = front_matter_end(&lines);
let mut in_record_section = false;
let mut record_heading_level: Option<usize> = None;
while i < lines.len() {
let raw = lines[i];
let trimmed = raw.trim();
if trimmed.is_empty() {
i += 1;
continue;
}
if trimmed.starts_with('#') {
let level = heading_level(trimmed);
let heading = trimmed.trim_start_matches('#').trim();
if is_history_heading(heading) {
in_record_section = true;
record_heading_level = Some(level);
} else if record_heading_level.is_some_and(|rlevel| level > rlevel) {
in_record_section = true;
} else {
in_record_section = false;
record_heading_level = None;
}
i += 1;
continue;
}
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
let fence = &trimmed[..3];
let open = i;
let mut close = i;
let mut j = i + 1;
while j < lines.len() {
if lines[j].trim_start().starts_with(fence) {
close = j;
break;
}
close = j;
j += 1;
}
let text = lines[open..=close].join("\n");
out.push(RawClaim {
lines: [open as u32 + 1, close as u32 + 1],
text,
in_record_section,
});
i = close + 1;
continue;
}
if is_list_marker(trimmed) {
let open = i;
let mut close = i;
let mut j = i + 1;
while j < lines.len() {
let t = lines[j].trim();
if is_block_boundary(t) || is_list_marker(t) {
break;
}
if lines[j].starts_with(' ') || lines[j].starts_with('\t') {
close = j;
j += 1;
continue;
}
break;
}
let joined = lines[open..=close]
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let text = strip_leading_marker(&joined).to_string();
out.push(RawClaim {
lines: [open as u32 + 1, close as u32 + 1],
text,
in_record_section,
});
i = close + 1;
continue;
}
let open = i;
let mut close = i;
let mut j = i + 1;
while j < lines.len() {
let t = lines[j].trim();
if is_block_boundary(t) || is_list_marker(t) {
break;
}
close = j;
j += 1;
}
out.extend(split_sentences(
&lines[open..=close],
open as u32 + 1,
in_record_section,
));
i = close + 1;
}
out
}
const RECORD_MARKERS: &[&str] = &[
"moved",
"renamed",
"replaced",
"deleted",
"retired",
"no longer",
"used to",
"until ",
"formerly",
"was at",
"is now",
"since 0.",
"since v",
];
const PRESENT_MARKERS: &[&str] = &[
"is at",
"is live",
"lives in",
"runs on",
"deployed via",
"is deployed",
"is the sync server",
"is currently",
"currently uses",
"is hosted",
];
fn looks_like_record(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
if lower.contains("not `") || lower.contains("not \"") {
return true;
}
RECORD_MARKERS.iter().any(|m| lower.contains(m))
}
fn strip_leading_marker(text: &str) -> &str {
let t = text.trim_start();
for prefix in ["- ", "* ", "+ "] {
if let Some(rest) = t.strip_prefix(prefix) {
return rest.trim_start();
}
}
let digits: String = t.chars().take_while(char::is_ascii_digit).collect();
if !digits.is_empty() {
let rest = &t[digits.len()..];
if let Some(after) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
return after.trim_start();
}
}
t
}
fn looks_like_present(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
if PRESENT_MARKERS.iter().any(|m| lower.contains(m)) {
return true;
}
let body = strip_leading_marker(text);
if body.starts_with("Run `") || body.starts_with("run `") {
return true;
}
let bare = body.trim().trim_end_matches('.').trim_matches('`');
bare.split_once('=')
.is_some_and(|(name, value)| is_envvarish(name) && !value.is_empty())
&& !bare.contains(char::is_whitespace)
}
fn classify(text: &str, in_record_section: bool, note_type: Option<&str>) -> Class {
if text.contains("[CONFLICT") {
return Class::Conflict;
}
if in_record_section || looks_like_record(text) {
return Class::Record;
}
if matches!(note_type, Some("user") | Some("feedback")) {
return Class::Rule;
}
if looks_like_present(text) {
return Class::Present;
}
Class::Unsure
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AnchorKind {
Path,
Hostname,
EnvVar,
SemVer,
Code,
}
struct Anchor {
kind: AnchorKind,
value: String,
}
const KNOWN_EXTENSIONS: &[&str] = &[
"sh", "bash", "zsh", "md", "rs", "py", "js", "ts", "json", "yml", "yaml", "toml", "ps1", "sql",
"txt", "log", "service", "conf", "env", "lock", "ini", "cfg", "html", "css", "rb", "go",
];
fn has_file_extension(tok: &str) -> bool {
tok.rfind('.')
.map(|pos| &tok[pos + 1..])
.is_some_and(|ext| {
!ext.is_empty() && KNOWN_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
})
}
fn looks_like_a_real_path_segment(tok: &str) -> bool {
const NOT_PATHS: &[&str] = &[
"and/or",
"his/her",
"he/she",
"yes/no",
"on/off",
"true/false",
"either/or",
"pass/fail",
];
if NOT_PATHS.contains(&tok.to_ascii_lowercase().as_str()) {
return false;
}
if let Some((a, b)) = tok.split_once('/') {
let numeric = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
if numeric(a) || numeric(b) {
return false;
}
}
true
}
fn is_windows_path(tok: &str) -> bool {
let b = tok.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')
}
fn is_machine_absolute(value: &str) -> bool {
value.starts_with('/') || value.starts_with("~/") || is_windows_path(value)
}
fn is_pathish(tok: &str) -> bool {
if tok.chars().all(|c| c == '/') {
return false;
}
tok.starts_with('/')
|| tok.starts_with("~/")
|| tok.starts_with("./")
|| tok.starts_with("../")
|| is_windows_path(tok)
|| (tok.contains('/') && !tok.contains("://") && looks_like_a_real_path_segment(tok))
|| has_file_extension(tok)
}
fn is_semverish(tok: &str) -> bool {
let t = tok.strip_prefix('v').unwrap_or(tok);
let parts: Vec<&str> = t.splitn(4, '.').collect();
parts.len() >= 3
&& parts[..3]
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}
fn is_envvarish(tok: &str) -> bool {
let name = tok.split('=').next().unwrap_or(tok);
name.len() >= 3
&& name.contains('_')
&& name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
&& name
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
fn is_hostish(tok: &str) -> bool {
if tok.starts_with("http://") || tok.starts_with("https://") {
return true;
}
if tok.contains("://") {
return false;
}
let t = tok.trim_end_matches('/');
let parts: Vec<&str> = t.split('.').collect();
parts.len() >= 2
&& parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
&& parts
.last()
.is_some_and(|tld| tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic()))
}
fn classify_anchor(tok: &str, in_code: bool) -> Option<Anchor> {
let kind = if is_envvarish(tok) {
AnchorKind::EnvVar
} else if is_semverish(tok) {
AnchorKind::SemVer
} else if is_pathish(tok) {
AnchorKind::Path
} else if is_hostish(tok) {
AnchorKind::Hostname
} else if in_code || tok.starts_with("--") {
AnchorKind::Code
} else {
return None;
};
Some(Anchor {
kind,
value: tok.to_string(),
})
}
fn trim_token(tok: &str) -> &str {
let tok = tok.trim_matches(|c: char| {
!(c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '~' | '_' | '-' | '='))
});
tok.strip_suffix('.').unwrap_or(tok)
}
fn anchors(text: &str) -> Vec<Anchor> {
let mut out = Vec::new();
let mut rest = text;
let mut plain = String::new();
while let Some(start) = rest.find('`') {
plain.push_str(&rest[..start]);
plain.push(' ');
let after = &rest[start + 1..];
let Some(end) = after.find('`') else {
plain.push_str(after);
rest = "";
break;
};
let inner = after[..end].trim();
if !inner.is_empty() {
if inner.contains(' ') {
if inner.starts_with("recall ") {
out.push(Anchor {
kind: AnchorKind::Code,
value: inner.to_string(),
});
}
} else if let Some(a) = classify_anchor(inner, true) {
out.push(a);
}
}
rest = &after[end + 1..];
}
plain.push_str(rest);
for tok in plain.split_whitespace() {
let tok = trim_token(tok);
if tok.is_empty() {
continue;
}
if let Some(a) = classify_anchor(tok, false) {
out.push(a);
}
}
out.sort_by(|a, b| (a.kind as u8, &a.value).cmp(&(b.kind as u8, &b.value)));
out.dedup_by(|a, b| a.kind == b.kind && a.value == b.value);
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Signal {
Confirms,
Contradicts,
Exists,
Unknown,
}
fn verdict_of_signal(s: Signal, class: Class) -> Verdict {
match s {
Signal::Confirms => Verdict::StillTrue,
Signal::Exists if class == Class::Rule => Verdict::StillTrue,
Signal::Contradicts => Verdict::Stale,
Signal::Exists | Signal::Unknown => Verdict::CantTell,
}
}
struct Observed {
signal: Signal,
source: String,
detail: String,
}
fn observed(signal: Signal, source: &str, detail: String) -> Observed {
Observed {
signal,
source: source.to_string(),
detail,
}
}
fn pathspec_for(value: &str) -> String {
if value.contains('/') || value.contains(['*', '?', '[']) {
format!(":(literal){value}")
} else {
format!(":(glob)**/{value}")
}
}
struct Repo {
root: Option<PathBuf>,
}
impl Repo {
fn at(root: Option<PathBuf>) -> Self {
Repo { root }
}
fn git(&self, args: &[&str]) -> Option<String> {
let root = self.root.as_ref()?;
let out = Command::new("git")
.args(args)
.current_dir(root)
.stdin(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn head_short(&self) -> Option<String> {
self.git(&["rev-parse", "--short", "HEAD"])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn tracked(&self, path: &str) -> bool {
let spec = pathspec_for(path);
self.git(&["ls-files", "--", &spec])
.is_some_and(|s| !s.trim().is_empty())
}
fn deleted(&self, path: &str) -> Option<(String, String)> {
let spec = pathspec_for(path);
let out = self.git(&[
"log",
"--diff-filter=D",
"-1",
"--format=%h %cs",
"--",
&spec,
])?;
let out = out.trim();
if out.is_empty() {
return None;
}
let (hash, date) = out.split_once(' ')?;
Some((hash.to_string(), date.to_string()))
}
fn references(&self, term: &str) -> Option<bool> {
let root = self.root.as_ref()?;
let out = Command::new("git")
.args(["grep", "-l", "-w", "-F", "-e", term, "--"])
.current_dir(root)
.stdin(Stdio::null())
.output()
.ok()?;
match out.status.code() {
Some(0) => {
let files = String::from_utf8_lossy(&out.stdout);
Some(
files
.lines()
.any(|f| !f.trim().to_ascii_lowercase().ends_with(".md")),
)
}
Some(1) => Some(false),
_ => None,
}
}
fn newest_tag(&self) -> Option<(u64, u64, u64, String)> {
let out = self.git(&["tag"])?;
out.lines()
.filter_map(parse_semver_tag)
.max_by_key(|t| (t.0, t.1, t.2))
}
}
fn parse_semver(text: &str) -> Option<(u64, u64, u64)> {
let t = text.strip_prefix('v').unwrap_or(text);
let mut parts = t.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch_str = parts.next()?;
let patch_digits: String = patch_str.chars().take_while(char::is_ascii_digit).collect();
let patch = patch_digits.parse().ok()?;
Some((major, minor, patch))
}
fn parse_semver_tag(tag: &str) -> Option<(u64, u64, u64, String)> {
let (major, minor, patch) = parse_semver(tag.trim())?;
Some((major, minor, patch, tag.trim().to_string()))
}
fn asserts_current_version(claim_text: &str) -> bool {
let lower = claim_text.to_ascii_lowercase();
[
"current version",
"currently on",
"is at version",
"is on version",
"is at v",
"latest version",
"version is",
"recall is at",
]
.iter()
.any(|p| lower.contains(p))
}
fn machine_path_exists(p: &str) -> bool {
let expanded = match p.strip_prefix("~/") {
Some(rest) => match std::env::var("HOME") {
Ok(home) if !home.is_empty() => format!("{home}/{rest}"),
_ => return false,
},
None => p.to_string(),
};
Path::new(&expanded).exists()
}
fn evidence_for_path(repo: &Repo, value: &str, is_project: bool) -> Observed {
if is_machine_absolute(value) {
return if machine_path_exists(value) {
observed(
Signal::Exists,
"machine",
format!("`{value}` exists on this machine"),
)
} else {
observed(
Signal::Unknown,
"machine",
format!(
"`{value}` was not found on this machine; the claim may be about another one"
),
)
};
}
if !is_project {
return observed(
Signal::Unknown,
"repository",
format!(
"`{value}` names a repository path, but this note is outside the project scope; \
the checkout only speaks for the project"
),
);
}
if repo.root.is_none() {
return observed(
Signal::Unknown,
"repository",
"this is not a git repository, so its history cannot be read".to_string(),
);
}
if repo.tracked(value) {
return observed(
Signal::Exists,
"repository",
format!("`{value}` exists at HEAD"),
);
}
match repo.deleted(value) {
Some((hash, date)) => observed(
Signal::Contradicts,
"git",
format!("`{value}` was deleted in {hash} ({date}) and is not at HEAD"),
),
None => observed(
Signal::Unknown,
"git",
format!("`{value}` is not at HEAD and git has no record of it ever being tracked"),
),
}
}
fn evidence_for_semver(
newest_tag: &Option<(u64, u64, u64, String)>,
value: &str,
is_project: bool,
claim_text: &str,
) -> Observed {
if !is_project {
return observed(
Signal::Unknown,
"repository",
format!(
"`{value}` would be checked against this project's release tags; this note is \
outside the project scope"
),
);
}
let Some(claimed) = parse_semver(value) else {
return observed(
Signal::Unknown,
"git",
format!("`{value}` is not a version this can parse"),
);
};
match newest_tag {
Some((major, minor, patch, tag)) => {
let newest = (*major, *minor, *patch);
if claimed == newest {
observed(
Signal::Confirms,
"git",
format!("matches the newest release tag {tag}"),
)
} else if claimed < newest && asserts_current_version(claim_text) {
observed(
Signal::Contradicts,
"git",
format!("the newest release tag is {tag}"),
)
} else if claimed < newest {
observed(
Signal::Unknown,
"git",
format!(
"older than the newest release tag {tag}; the claim may be about when \
something shipped, not the current version"
),
)
} else {
observed(
Signal::Unknown,
"git",
format!("newer than the newest release tag {tag}; nothing here can tell why"),
)
}
}
None => observed(Signal::Unknown, "git", "no release tags found".to_string()),
}
}
fn evidence_for_code(repo: &Repo, value: &str, is_project: bool) -> Observed {
if !is_project {
return observed(
Signal::Unknown,
"repository",
format!(
"`{value}` would be checked against this project's tracked tree; this note is \
outside the project scope"
),
);
}
match repo.references(value) {
Some(true) => observed(
Signal::Exists,
"git",
format!("`{value}` is still referenced in the tracked tree, outside documentation"),
),
Some(false) => observed(
Signal::Unknown,
"git",
format!(
"`{value}` was not found outside documentation in the tracked tree; that alone \
does not make it gone"
),
),
None => observed(
Signal::Unknown,
"git",
"this is not a git repository, so it cannot be searched".to_string(),
),
}
}
fn evidence_for(
anchor: &Anchor,
repo: &Repo,
is_project: bool,
newest_tag: &Option<(u64, u64, u64, String)>,
claim_text: &str,
facts: &sources::Facts,
) -> Observed {
let value = anchor.value.as_str();
match anchor.kind {
AnchorKind::Path => evidence_for_path(repo, value, is_project),
AnchorKind::SemVer => parse_semver(value)
.and_then(|v| sources::evidence_for_server_version(v, claim_text, facts))
.unwrap_or_else(|| evidence_for_semver(newest_tag, value, is_project, claim_text)),
AnchorKind::Code => sources::evidence_for_name(value, facts, is_project)
.unwrap_or_else(|| evidence_for_code(repo, value, is_project)),
AnchorKind::Hostname => sources::evidence_for_host(value, claim_text, facts),
AnchorKind::EnvVar => {
if let Some(o) = sources::evidence_for_env_value(value, claim_text, facts) {
return o;
}
let name = value.split('=').next().unwrap_or(value);
sources::evidence_for_env_name(name, facts, is_project)
.unwrap_or_else(|| evidence_for_code(repo, name, is_project))
}
}
}
fn verdict_from(observed: &[Observed]) -> Option<Verdict> {
if observed.is_empty() {
return None;
}
let any = |s: Signal| observed.iter().any(|o| o.signal == s);
if any(Signal::Contradicts) {
Some(Verdict::Stale)
} else if any(Signal::Confirms) && !any(Signal::Unknown) {
Some(Verdict::StillTrue)
} else {
Some(Verdict::CantTell)
}
}
fn lines_desc(l: &[u32; 2]) -> String {
if l[0] == l[1] {
format!("L{}", l[0])
} else {
format!("L{}-{}", l[0], l[1])
}
}
fn sanitize_for_terminal(s: &str) -> String {
crate::edit::printable(s).replace('\n', " ")
}
const CLAIM_WIDTH: usize = 96;
const DETAIL_WIDTH: usize = 104;
fn verdict_mark(v: Verdict) -> String {
let (mark, tone) = match v {
Verdict::Stale => ("✗", ui::Tone::Bad),
Verdict::StillTrue => ("✓", ui::Tone::Good),
Verdict::CantTell => ("?", ui::Tone::Warn),
};
ui::toned(tone, mark)
}
fn claim_line(mark: &str, c: &Claim) {
anstream::println!(
" {mark} {} {} {}",
ui::bold(&format!("{:<4}", c.id)),
ui::dim(&format!("{:<7}", lines_desc(&c.lines))),
sanitize_for_terminal(&ui::clip(&c.text, CLAIM_WIDTH))
);
}
fn under_claim(mark: &str, text: &str) {
anstream::println!(
" {mark} {}",
sanitize_for_terminal(&ui::clip(text, DETAIL_WIDTH))
);
}
fn next_for_stale(c: &Claim) -> String {
match &c.suggested_edit {
Some(_) => format!("recall review apply {}", c.id),
None => format!("edit {} {}", c.file, lines_desc(&c.lines)),
}
}
fn print_text(rep: &Report) {
let mut about = Vec::new();
if let Some(head) = &rep.evidence.repository_head {
about.push(format!("checkout {head}"));
}
if let Some(v) = &rep.evidence.server_version {
about.push(format!("server {v}"));
}
if let Some(id) = &rep.evidence.evaluation {
about.push(format!("report {id}"));
}
ui::title("recall review", &about.join(" · "));
if rep.claims.is_empty() {
anstream::println!();
anstream::println!("Nothing to review: no memory files with checkable content were found.");
return;
}
let count = |f: &dyn Fn(&Claim) -> bool| rep.claims.iter().filter(|c| f(c)).count();
let verdicts_of = |c: &Claim| -> Vec<Verdict> {
match c.class {
Class::Present => c.verdict.into_iter().collect(),
Class::Rule => c.evidence.iter().map(|e| e.verdict).collect(),
_ => Vec::new(),
}
};
let tally = |v: Verdict| {
rep.claims
.iter()
.map(|c| verdicts_of(c).iter().filter(|x| **x == v).count())
.sum::<usize>()
};
let (stale, still_true, cant_tell) = (
tally(Verdict::Stale),
tally(Verdict::StillTrue),
tally(Verdict::CantTell),
);
let records = count(&|c| c.class == Class::Record);
let conflicts = count(&|c| c.class == Class::Conflict);
let undecided =
count(&|c| c.class == Class::Unsure || (c.class == Class::Present && c.verdict.is_none()));
anstream::println!();
let mut parts = vec![
ui::toned(
if stale > 0 {
ui::Tone::Bad
} else {
ui::Tone::Quiet
},
&format!("✗ {stale} stale"),
),
ui::toned(
if still_true > 0 {
ui::Tone::Good
} else {
ui::Tone::Quiet
},
&format!("✓ {still_true} still true"),
),
ui::toned(
if cant_tell > 0 {
ui::Tone::Warn
} else {
ui::Tone::Quiet
},
&format!("? {cant_tell} can't tell"),
),
];
if conflicts > 0 {
parts.push(ui::toned(
ui::Tone::Warn,
&format!("! {conflicts} conflict(s)"),
));
}
if records > 0 {
parts.push(ui::dim(&format!("{records} record(s)")));
}
if undecided > 0 {
parts.push(ui::dim(&format!("{undecided} not decided")));
}
anstream::println!(" {}", parts.join(" "));
let mut by_file: BTreeMap<&str, Vec<&Claim>> = BTreeMap::new();
for c in &rep.claims {
by_file.entry(c.file.as_str()).or_default().push(c);
}
let worst = |claims: &[&Claim]| -> u8 {
let vs: Vec<Verdict> = claims.iter().flat_map(|c| verdicts_of(c)).collect();
if vs.contains(&Verdict::Stale) || claims.iter().any(|c| c.class == Class::Conflict) {
0
} else if vs.contains(&Verdict::CantTell) {
1
} else if !vs.is_empty() || claims.iter().any(|c| !c.eval.is_empty()) {
2
} else {
3
}
};
let mut files: Vec<(&str, &Vec<&Claim>)> = by_file.iter().map(|(f, c)| (*f, c)).collect();
files.sort_by_key(|(f, c)| (worst(c), *f));
let mut quiet_files = Vec::new();
let mut next: Vec<String> = Vec::new();
for (file, claims) in &files {
if worst(claims) == 3 {
quiet_files.push(*file);
continue;
}
anstream::println!();
anstream::println!("{}", ui::bold(&sanitize_for_terminal(file)));
let mut order: Vec<&&Claim> = claims
.iter()
.filter(|c| {
matches!(c.class, Class::Conflict | Class::Rule)
|| (c.class == Class::Present && c.verdict.is_some())
|| (c.class == Class::Unsure && c.layer == Some(3))
})
.collect();
let rank = |c: &Claim| match (c.class, c.verdict) {
(Class::Conflict, _) => 0,
(_, Some(Verdict::Stale)) => 1,
(Class::Rule, _) if c.evidence.iter().any(|e| e.verdict == Verdict::Stale) => 1,
(_, Some(Verdict::CantTell)) => 2,
(Class::Rule, _) => 3,
(_, Some(Verdict::StillTrue)) => 4,
_ => 5,
};
order.sort_by_key(|c| (rank(c), c.lines[0]));
for c in order {
match (c.class, c.verdict) {
(Class::Conflict, _) => {
claim_line(&ui::toned(ui::Tone::Warn, "!"), c);
under_claim(
&ui::toned(ui::Tone::Warn, "!"),
"a merge left a conflict here; edit the file to keep one version",
);
}
(Class::Rule, _) => {
claim_line(&ui::dim("§"), c);
for e in &c.evidence {
under_claim(&verdict_mark(e.verdict), &e.detail);
}
}
(Class::Unsure, _) => {
claim_line(&ui::dim("·"), c);
for e in &c.evidence {
under_claim(&ui::dim("·"), &e.detail);
}
}
(_, Some(v)) => {
claim_line(&verdict_mark(v), c);
for e in &c.evidence {
under_claim(&verdict_mark(e.verdict), &e.detail);
}
if v == Verdict::Stale {
if let Some(edit) = &c.suggested_edit {
under_claim(
&ui::accent("→"),
&format!("suggested: {}", edit.replacement.trim()),
);
}
let step = next_for_stale(c);
under_claim(&ui::accent("→"), &step);
if c.suggested_edit.is_some() {
next.push(step);
}
}
}
_ => {}
}
}
let file_records = claims.iter().filter(|c| c.class == Class::Record).count();
let file_undecided = claims
.iter()
.filter(|c| {
(c.class == Class::Unsure && c.layer != Some(3))
|| (c.class == Class::Present && c.verdict.is_none())
})
.count();
let mut counted = Vec::new();
if file_records > 0 {
counted.push(format!("{file_records} record(s)"));
}
if file_undecided > 0 {
counted.push(format!("{file_undecided} not decided by any check"));
}
if !counted.is_empty() {
anstream::println!(" {}", ui::dim(&format!("· {}", counted.join(", "))));
}
let mut found: BTreeMap<(&str, &str), (&reports::EvalFinding, Vec<String>)> =
BTreeMap::new();
for c in claims.iter() {
for e in &c.eval {
found
.entry((e.evaluation.as_str(), e.finding.as_str()))
.or_insert((e, Vec::new()))
.1
.push(format!("{} ({})", c.id, lines_desc(&c.lines)));
}
}
for (e, ids) in found.values() {
let tone = match e.severity.as_str() {
"high" => ui::Tone::Bad,
"medium" => ui::Tone::Warn,
_ => ui::Tone::Quiet,
};
anstream::println!(
" {} {} {} {} ({}), on {} {}",
ui::toned(tone, "◆"),
sanitize_for_terminal(&e.evaluation),
sanitize_for_terminal(&e.finding),
ui::toned(tone, &sanitize_for_terminal(&e.kind)),
sanitize_for_terminal(&e.severity),
ids.join(", "),
ui::accent(&format!(
"→ recall eval show {}",
sanitize_for_terminal(&e.evaluation)
))
);
}
}
if !quiet_files.is_empty() {
anstream::println!();
anstream::println!(
"{}",
ui::dim(&format!(
"No check could decide anything in: {}",
quiet_files
.iter()
.map(|f| sanitize_for_terminal(f))
.collect::<Vec<_>>()
.join(", ")
))
);
}
if let Some(run) = &rep.evidence.claude {
anstream::println!();
anstream::println!(
"{} {}",
ui::bold("claude"),
ui::dim(&format!(
"{} call(s), {} file(s) unchanged since it was last asked",
run.calls, run.reused
))
);
for s in &run.skipped {
anstream::println!(
" {} {}: {}",
ui::toned(ui::Tone::Warn, "!"),
sanitize_for_terminal(&s.file),
sanitize_for_terminal(&ui::clip(&s.reason, DETAIL_WIDTH))
);
}
}
let waiting = rep
.evidence
.unavailable
.iter()
.find(|u| u.source == "claude")
.is_some();
if waiting && undecided > 0 {
next.push(format!(
"recall review run --claude, to decide what no check could ({undecided} claim(s))"
));
}
for u in rep
.evidence
.unavailable
.iter()
.filter(|u| !matches!(u.source.as_str(), "claude" | "compose"))
{
anstream::println!(
"{} {} {}",
ui::toned(ui::Tone::Quiet, "○"),
ui::dim(&format!("{} not read:", u.source)),
ui::dim(&sanitize_for_terminal(&ui::clip(&u.reason, DETAIL_WIDTH)))
);
}
if !next.is_empty() {
anstream::println!();
anstream::println!("{}", ui::bold("Next"));
for step in next.iter().take(8) {
anstream::println!(" {}", ui::accent(&format!("→ {step}")));
}
if next.len() > 8 {
anstream::println!(" {}", ui::dim(&format!("… and {} more", next.len() - 8)));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn claims_of(content: &str) -> Vec<(Class, String, [u32; 2])> {
let note_type = front_matter_type(content);
extract_claims(content)
.into_iter()
.map(|raw| {
let class = classify(&raw.text, raw.in_record_section, note_type.as_deref());
(class, raw.text, raw.lines)
})
.collect()
}
fn git_fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for args in [
vec!["init", "-q"],
vec!["config", "user.email", "t@example.com"],
vec!["config", "user.name", "Test"],
] {
assert!(Command::new("git")
.args(&args)
.current_dir(dir.path())
.status()
.unwrap()
.success());
}
dir
}
fn git_commit(dir: &Path, message: &str) {
assert!(Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.status()
.unwrap()
.success());
assert!(Command::new("git")
.args(["commit", "-q", "--allow-empty", "-m", message])
.current_dir(dir)
.status()
.unwrap()
.success());
}
#[test]
fn a_list_item_is_one_claim_with_its_line_range() {
let content = "- The server is at `x`.\n- Second one.\n";
let claims = claims_of(content);
assert_eq!(claims.len(), 2, "{claims:?}");
assert_eq!(claims[0].2, [1, 1]);
assert_eq!(claims[1].2, [2, 2]);
}
#[test]
fn a_fenced_block_is_one_claim() {
let content = "Some text.\n\n```sh\nrecall status\necho hi\n```\n";
let claims = claims_of(content);
let fence = claims
.iter()
.find(|c| c.1.contains("```"))
.expect("a fenced claim");
assert_eq!(fence.2, [3, 6], "{:?}", fence.2);
}
#[test]
fn front_matter_is_never_a_claim() {
let content = "---\nname: x\ntype: user\n---\n\nPrefers tabs.\n";
let claims = claims_of(content);
assert!(claims.iter().all(|c| !c.1.contains("type: user")));
}
#[test]
fn a_type_user_note_classifies_every_claim_as_rule() {
let content = "---\ntype: user\n---\n\nPrefers tabs over spaces.\n";
let claims = claims_of(content);
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].0, Class::Rule);
}
#[test]
fn a_correction_inside_a_feedback_note_is_a_record_not_a_rule() {
let content =
"---\ntype: feedback\n---\n\nNot `lib.sh` any more, it was deleted in the rewrite.\n";
let claims = claims_of(content);
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].0, Class::Record, "{:?}", claims[0]);
}
#[test]
fn a_history_heading_makes_every_claim_under_it_a_record() {
let content = "It is at `x`.\n\n## History\n\nIt used to be at `y`.\n";
let claims = claims_of(content);
assert_eq!(claims.len(), 2);
assert_eq!(claims[0].0, Class::Present, "{:?}", claims[0]);
assert_eq!(claims[1].0, Class::Record, "{:?}", claims[1]);
}
#[test]
fn a_nested_heading_under_history_stays_a_record() {
let content =
"## History\n\n### 2026-09\n\nIt used to be at `y`.\n\n## Now\n\nIt is at `x`.\n";
let claims = claims_of(content);
assert_eq!(claims.len(), 2);
assert_eq!(
claims[0].0,
Class::Record,
"nested under History: {:?}",
claims[0]
);
assert_eq!(
claims[1].0,
Class::Present,
"back to a sibling section: {:?}",
claims[1]
);
}
#[test]
fn a_negated_anchor_is_a_record_even_without_a_heading() {
let content =
"Not the old `hooks/recall-pull` script, and not `lib.sh`: both were deleted.\n";
let claims = claims_of(content);
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].0, Class::Record);
}
#[test]
fn a_conflict_marker_is_its_own_class_whatever_else_is_true() {
let content = "The rate limit is `[CONFLICT: 10/min vs 100/min]` per minute.\n";
let claims = claims_of(content);
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].0, Class::Conflict);
}
#[test]
fn plain_prose_with_no_present_or_record_signal_is_unsure() {
let content = "Something about the weather today, which is nice.\n";
let claims = claims_of(content);
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].0, Class::Unsure);
}
#[test]
fn a_bare_assignment_is_a_present_claim() {
for line in [
"- CLAUDE_CODE_REMOTE_MEMORY_DIR=/home/user/.claude\n",
"- `RECALL_URL=https://recall.example.com`.\n",
] {
assert_eq!(claims_of(line)[0].0, Class::Present, "{line:?}");
}
for line in [
"- Maybe try FOO_BAR=1 when it misbehaves.\n",
"- FOO_BAR=\n",
] {
assert_eq!(claims_of(line)[0].0, Class::Unsure, "{line:?}");
}
}
#[test]
fn anchors_recognise_every_kind_the_design_names() {
let text =
"`lib.sh` `recall.pimlabs.id` `RECALL_URL` `0.4.8` `--json` `/home/user/.claude`";
let found = anchors(text);
let kinds: Vec<AnchorKind> = found.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&AnchorKind::Path), "{kinds:?}");
assert!(kinds.contains(&AnchorKind::Hostname), "{kinds:?}");
assert!(kinds.contains(&AnchorKind::EnvVar), "{kinds:?}");
assert!(kinds.contains(&AnchorKind::SemVer), "{kinds:?}");
assert!(kinds.contains(&AnchorKind::Code), "{kinds:?}");
}
#[test]
fn a_bare_english_word_outside_code_is_not_an_anchor() {
let found = anchors("recall is a tool that syncs memory reliably across machines");
assert!(
found.is_empty(),
"{:?}",
found.iter().map(|a| &a.value).collect::<Vec<_>>()
);
}
#[test]
fn common_idioms_and_fractions_are_not_path_anchors() {
for tok in ["and/or", "10/min", "on/off", "1/2", "/", "//"] {
assert!(!is_pathish(tok), "{tok:?} should not look like a path");
}
assert!(is_pathish("hooks/recall-pull"), "a real path still does");
}
#[test]
fn a_windows_drive_path_is_a_path_anchor_not_code() {
for tok in [r"C:\Users\x\.claude", "C:/Users/x/.claude"] {
let found = anchors(&format!("`{tok}`"));
assert_eq!(
found.len(),
1,
"{tok:?}: {:?}",
found.iter().map(|a| &a.value).collect::<Vec<_>>()
);
assert_eq!(found[0].kind, AnchorKind::Path, "{tok:?}");
}
}
#[test]
fn a_hostname_with_no_server_configured_cant_tell() {
let repo = Repo::at(None);
let o = evidence_for(
&Anchor {
kind: AnchorKind::Hostname,
value: "recall.pimlabs.id".into(),
},
&repo,
true,
&None,
"",
&sources::Facts::default(),
);
assert_eq!(o.signal, Signal::Unknown);
assert_eq!(o.source, "server");
}
#[test]
fn a_record_never_gets_a_verdict() {
let repo = Repo::at(None);
let (verdict, layer, evidence) = judge(
Class::Record,
"Not the old `hooks/recall-pull` script, and not `lib.sh`.",
&repo,
true,
&None,
&sources::Facts::default(),
);
assert_eq!(verdict, None);
assert_eq!(layer, None);
assert!(evidence.is_empty());
}
#[test]
fn an_unsure_claim_gets_no_verdict_in_this_pr() {
let repo = Repo::at(None);
let (verdict, layer, _) = judge(
Class::Unsure,
"`lib.sh` is mentioned here.",
&repo,
true,
&None,
&sources::Facts::default(),
);
assert_eq!(verdict, None);
assert_eq!(layer, None);
}
#[test]
fn a_rule_gets_no_claim_level_verdict_but_each_anchor_does() {
let dir = git_fixture();
std::fs::write(dir.path().join("lib.sh"), "echo hi\n").unwrap();
git_commit(dir.path(), "add");
std::fs::remove_file(dir.path().join("lib.sh")).unwrap();
git_commit(dir.path(), "remove");
let repo = Repo::at(Some(dir.path().to_path_buf()));
let (verdict, _layer, evidence) = judge(
Class::Rule,
"Use `lib.sh` for this.",
&repo,
true,
&None,
&sources::Facts::default(),
);
assert_eq!(verdict, None, "a rule's substance is never judged as one");
assert_eq!(evidence.len(), 1);
assert_eq!(evidence[0].verdict, Verdict::Stale, "{evidence:?}");
}
#[test]
fn existence_decides_a_rule_anchor_but_not_a_present_claim() {
let dir = git_fixture();
std::fs::write(dir.path().join("config.rs"), "// RECALL_HOST\n").unwrap();
git_commit(dir.path(), "add");
let repo = Repo::at(Some(dir.path().to_path_buf()));
let judged =
|class, text| judge(class, text, &repo, true, &None, &sources::Facts::default());
let (verdict, _, evidence) = judged(
Class::Present,
"`config.rs` reads `RECALL_HOST` to pick the bind address.",
);
assert_eq!(verdict, Some(Verdict::CantTell), "{evidence:?}");
assert_eq!(evidence.len(), 2, "{evidence:?}");
for e in &evidence {
assert_eq!(e.verdict, Verdict::CantTell, "{e:?}");
assert!(e.citable, "existence is still a fact: {e:?}");
}
let (verdict, _, evidence) = judged(Class::Rule, "Keep `config.rs` as it is.");
assert_eq!(verdict, None);
assert_eq!(evidence[0].verdict, Verdict::StillTrue, "{evidence:?}");
}
#[test]
fn a_matching_value_decides_a_present_claim_whose_other_anchors_exist() {
let exists = observed(Signal::Exists, "repository", String::new());
let confirms = observed(Signal::Confirms, "git", String::new());
let unknown = observed(Signal::Unknown, "git", String::new());
let both = [exists, confirms];
assert_eq!(verdict_from(&both), Some(Verdict::StillTrue));
assert_eq!(verdict_from(&both[..1]), Some(Verdict::CantTell));
let [_, confirms] = both;
assert_eq!(verdict_from(&[confirms, unknown]), Some(Verdict::CantTell));
}
#[test]
fn a_non_project_scope_gets_cant_tell_from_repository_checks() {
let dir = git_fixture();
std::fs::write(dir.path().join("lib.sh"), "echo hi\n").unwrap();
git_commit(dir.path(), "add");
std::fs::remove_file(dir.path().join("lib.sh")).unwrap();
git_commit(dir.path(), "remove");
let repo = Repo::at(Some(dir.path().to_path_buf()));
let o = evidence_for_path(&repo, "lib.sh", false);
assert_eq!(
o.signal,
Signal::Unknown,
"{o:?}",
o = (o.source.clone(), o.detail.clone())
);
}
#[test]
fn an_older_version_is_only_stale_when_the_claim_asserts_its_current() {
let dir = git_fixture();
git_commit(dir.path(), "x");
assert!(Command::new("git")
.args(["tag", "v0.4.8"])
.current_dir(dir.path())
.status()
.unwrap()
.success());
let newest = Repo::at(Some(dir.path().to_path_buf())).newest_tag();
let historical = evidence_for_semver(
&newest,
"0.4.5",
true,
"`--contradictions` shipped in 0.4.5.",
);
assert_eq!(historical.signal, Signal::Unknown, "{}", historical.detail);
let current = evidence_for_semver(&newest, "0.4.5", true, "Recall is at version 0.4.5.");
assert_eq!(current.signal, Signal::Contradicts, "{}", current.detail);
}
#[test]
fn semver_evidence_confirms_the_newest_tag() {
let dir = git_fixture();
git_commit(dir.path(), "x");
assert!(Command::new("git")
.args(["tag", "v0.4.8"])
.current_dir(dir.path())
.status()
.unwrap()
.success());
let newest = Repo::at(Some(dir.path().to_path_buf())).newest_tag();
let current = evidence_for_semver(&newest, "0.4.8", true, "Recall is at version 0.4.8.");
assert_eq!(current.signal, Signal::Confirms, "{}", current.detail);
}
#[test]
fn hostile_terms_are_never_interpreted_as_git_options() {
let dir = git_fixture();
std::fs::write(dir.path().join("code.rs"), "fn main() {}\n").unwrap();
git_commit(dir.path(), "x");
let repo = Repo::at(Some(dir.path().to_path_buf()));
for hostile in [
"-f-",
"-fLICENSE",
"--contradictions",
"-O/bin/sh",
"-x",
"-h",
] {
let result = repo.references(hostile);
assert_eq!(
result,
Some(false),
"hostile term {hostile:?} must read as not-found, not as a git option"
);
}
}
#[test]
fn a_flag_that_is_still_referenced_is_found() {
let dir = git_fixture();
std::fs::write(dir.path().join("code.rs"), "let flag = \"--keep-open\";\n").unwrap();
git_commit(dir.path(), "x");
let repo = Repo::at(Some(dir.path().to_path_buf()));
assert_eq!(repo.references("--keep-open"), Some(true));
}
#[test]
fn a_name_mentioned_only_in_markdown_is_not_found() {
let dir = git_fixture();
std::fs::write(
dir.path().join("notes.md"),
"hooks/recall-pull used to exist\n",
)
.unwrap();
git_commit(dir.path(), "x");
let repo = Repo::at(Some(dir.path().to_path_buf()));
assert_eq!(repo.references("recall-pull"), Some(false));
}
#[test]
fn an_absent_code_term_is_cant_tell_never_stale() {
let dir = git_fixture();
git_commit(dir.path(), "x");
let repo = Repo::at(Some(dir.path().to_path_buf()));
let o = evidence_for_code(&repo, "never-existed-anywhere", true);
assert_eq!(o.signal, Signal::Unknown, "{}", o.detail);
}
#[test]
fn a_bare_filename_matches_the_real_file_in_a_subdirectory() {
let dir = git_fixture();
std::fs::create_dir_all(dir.path().join("hooks")).unwrap();
std::fs::write(dir.path().join("hooks").join("lib.sh"), "echo hi\n").unwrap();
git_commit(dir.path(), "add");
let repo = Repo::at(Some(dir.path().to_path_buf()));
assert!(
repo.tracked("lib.sh"),
"bare lib.sh should find hooks/lib.sh"
);
std::fs::remove_file(dir.path().join("hooks").join("lib.sh")).unwrap();
git_commit(dir.path(), "remove");
assert!(!repo.tracked("lib.sh"));
assert!(
repo.deleted("lib.sh").is_some(),
"the deletion should still be found by name"
);
}
#[test]
fn a_quoted_glob_is_matched_literally_not_expanded() {
let dir = git_fixture();
std::fs::create_dir_all(dir.path().join("hooks")).unwrap();
std::fs::write(dir.path().join("hooks").join("recall-pull"), "x\n").unwrap();
git_commit(dir.path(), "add");
let repo = Repo::at(Some(dir.path().to_path_buf()));
assert!(
!repo.tracked("hooks/recall-*"),
"a literal glob-looking path must not match every file under hooks/"
);
}
#[test]
fn this_module_never_touches_the_network() {
let src = include_str!("review.rs");
let forbidden = [
["req", "west"].concat(),
["std::", "net::"].concat(),
["Tcp", "Stream"].concat(),
];
for word in &forbidden {
assert!(!src.contains(word.as_str()), "found {word:?} in review.rs");
}
}
}