use recall_wire::SuggestedEdit;
use super::{claude, extract_claims, load_state, Verdict};
use crate::edit::{self, refused, Hints};
use crate::project as proj;
const COMMAND: &str = "recall review";
const HINTS: Hints = Hints {
rerun: "recall review run --claude",
again: "recall review apply",
};
fn line_starts(content: &str) -> Vec<usize> {
let mut starts = vec![0];
starts.extend(content.match_indices('\n').map(|(i, _)| i + 1));
if *starts.last().unwrap() != content.len() {
starts.push(content.len());
}
starts
}
fn block_of(content: &str, lines: [u32; 2]) -> Option<(usize, usize)> {
let starts = line_starts(content);
let first = lines[0].checked_sub(1)? as usize;
let last = lines[1] as usize;
if first >= last || last >= starts.len() {
return None;
}
Some((starts[first], starts[last]))
}
pub(super) fn span_of(content: &str, lines: [u32; 2], text: &str) -> Option<(usize, usize)> {
let (start, end) = block_of(content, lines)?;
let block = &content[start..end];
let wanted: Vec<&str> = text.split_whitespace().collect();
if wanted.is_empty() {
return None;
}
let mut spans: Vec<(usize, usize)> = Vec::new();
let mut at = None;
for (i, c) in block.char_indices() {
match (c.is_whitespace(), at) {
(true, Some(s)) => {
spans.push((s, i));
at = None;
}
(false, None) => at = Some(i),
_ => {}
}
}
if let Some(s) = at {
spans.push((s, block.len()));
}
let mut found = (0..(spans.len() + 1).saturating_sub(wanted.len())).filter(|&i| {
wanted
.iter()
.zip(&spans[i..])
.all(|(w, &(a, b))| &block[a..b] == *w)
});
let i = found.next()?;
if found.next().is_some() {
return None;
}
Some((start + spans[i].0, start + spans[i + wanted.len() - 1].1))
}
pub(super) fn build(
content: &str,
lines: [u32; 2],
text: &str,
(project_key, file_path): (&str, &str),
rewrite: &str,
) -> Result<SuggestedEdit, String> {
let fence = text.trim_start();
if text.contains('\n') || fence.starts_with("```") || fence.starts_with("~~~") {
return Err("it is a block, and only prose is rewritten".into());
}
if !claude::rewrite_ok(text, rewrite) {
return Err("its rewrite is not a one-line record of what changed".into());
}
let redactor = recall_worker::Redactor::new(&[recall_wire::EvaluateFile {
file_path: file_path.to_string(),
content: content.to_string(),
..Default::default()
}]);
if redactor.text(text) != text {
return Err("it holds a secret, which claude only saw masked".into());
}
let (from, to) =
span_of(content, lines, text).ok_or("its words are not in its lines exactly once")?;
for other in extract_claims(content) {
let overlaps = other.lines[0] <= lines[1] && other.lines[1] >= lines[0];
if !overlaps || (other.lines == lines && other.text == text) {
continue;
}
match span_of(content, other.lines, &other.text) {
Some((a, b)) if b <= from || a >= to => {}
_ => {
return Err(format!(
"it would touch another claim on its lines ({}: \"{}\")",
edit::lines(&other.lines),
edit::printable(&other.text)
))
}
}
}
let (start, end) = block_of(content, lines).ok_or("its lines are not in the file")?;
Ok(SuggestedEdit {
project_key: project_key.to_string(),
file_path: file_path.to_string(),
base_sha256: recall_wire::content_sha256(content),
lines,
replacement: format!("{}{rewrite}{}", &content[start..from], &content[to..end]),
})
}
pub(super) async fn run(id: &str, yes: bool) -> anyhow::Result<i32> {
let here = proj::resolve();
let saved = load_state(&here.review_file());
let Some(report) = &saved.report else {
return Ok(edit::said(
COMMAND,
refused("there is no review yet.", "recall review run makes one."),
));
};
let Some(claim) = report.claims.iter().find(|c| c.id == id) else {
return Ok(edit::said(
COMMAND,
refused(
format!("the last review has no claim {id}."),
"recall review show lists them.",
),
));
};
let no_edit = || {
let then = if claim.verdict == Some(Verdict::Stale) {
"recall review run --claude asks for one."
} else {
""
};
Ok(edit::said(
COMMAND,
refused(
format!(
"{id} has no edit to make: only a stale claim claude was asked about \
carries one."
),
then,
),
))
};
if claim.suggested_edit.is_none() {
return no_edit();
}
let state = saved.files.get(&claim.file);
let Some(rewrite) = state
.and_then(|s| s.claude.as_ref())
.and_then(|r| {
r.decided
.iter()
.find(|d| d.lines == claim.lines && d.text == claim.text)
})
.map(claude::Decided::checked)
.and_then(|d| d.rewrite)
else {
return no_edit();
};
let ctx = match here.hook_context() {
Ok(ctx) => ctx,
Err(e) => return Ok(edit::said(COMMAND, refused(format!("{e:#}"), ""))),
};
let Some((scope, path)) = recall_hooks::scope::route(&ctx.scopes, &claim.file) else {
return Ok(edit::said(
COMMAND,
refused(
format!("{} is not in a scope that is on here.", claim.file),
"",
),
));
};
let local = super::join_relative(&ctx.memory_dir, &claim.file);
let content = std::fs::read_to_string(&local).unwrap_or_default();
if state.map(|s| s.content_sha256.as_str()) != Some(&recall_wire::content_sha256(&content)) {
return Ok(edit::said(
COMMAND,
refused(
format!(
"{} has changed since the review read it, so {id} may no longer fit.",
claim.file
),
format!("Make a new review: {}", HINTS.rerun),
),
));
}
let suggested = match build(
&content,
claim.lines,
&claim.text,
(&scope.key, &path),
&rewrite,
) {
Ok(edit) => edit,
Err(why) => {
return Ok(edit::said(
COMMAND,
refused(
format!("{id} was not rewritten: {why}."),
"Edit the file yourself if it needs it.",
),
))
}
};
let heading = format!("{id} stale {}, {}", claim.file, edit::lines(&claim.lines));
let code = edit::apply_and_push(COMMAND, id, &heading, &suggested, HINTS, yes).await;
if code == recall_hooks::exit::OK {
println!("recall review run checks it again.");
}
Ok(code)
}
#[cfg(test)]
mod tests {
use super::*;
const AT: (&str, &str) = ("acme/app", "deploy.md");
const REWRITE: &str = "Until 2026-09 the server was at `a.example`; it is now at `b.example`.";
#[test]
fn only_the_claims_words_are_replaced() {
let content = "# Deploy\n- The server is at `a.example`.\n";
let edit = build(
content,
[2, 2],
"The server is at `a.example`.",
AT,
REWRITE,
)
.unwrap();
assert_eq!(edit.replacement, format!("- {REWRITE}\n"));
assert_eq!(edit.lines, [2, 2]);
assert_eq!(edit.base_sha256, recall_wire::content_sha256(content));
assert_eq!(
edit.apply_to(content).unwrap(),
format!("# Deploy\n- {REWRITE}\n")
);
}
#[test]
fn a_wrapped_neighbour_is_kept_not_refused() {
let content = "The server runs Traefik on\nthe box. The server is at `a.example`. It used to\nbe at `z.example`.\n";
let edit = build(
content,
[2, 2],
"The server is at `a.example`.",
AT,
REWRITE,
)
.unwrap();
assert_eq!(
edit.apply_to(content).unwrap(),
format!(
"The server runs Traefik on\nthe box. {REWRITE} It used to\nbe at `z.example`.\n"
)
);
}
#[test]
fn an_edit_that_would_touch_another_claim_is_refused() {
let content = "The server is at `a.example`. It used to be at `z.example`.\n";
let err = build(
content,
[1, 1],
"The server is at `a.example`. It used to",
AT,
REWRITE,
)
.unwrap_err();
assert!(err.contains("another claim"), "{err}");
}
#[test]
fn a_fenced_block_is_never_rewritten() {
let content = "```\nssh deploy@old # server is at `a.example`\n```\n";
let text = "```\nssh deploy@old # server is at `a.example`\n```";
assert!(build(content, [1, 3], text, AT, REWRITE).is_err());
assert!(build(content, [1, 3], "``` ssh deploy@old", AT, REWRITE).is_err());
}
#[test]
fn words_found_twice_or_not_at_all_are_refused() {
let twice = "It is at `a.example`. It is at `a.example`.\n";
assert!(build(twice, [1, 1], "It is at `a.example`.", AT, REWRITE).is_err());
let content = "- The server is at `a.example`.\n";
assert!(build(content, [1, 1], "Not in the file.", AT, REWRITE).is_err());
assert!(build(
content,
[5, 5],
"The server is at `a.example`.",
AT,
REWRITE
)
.is_err());
}
#[test]
fn a_claim_holding_a_secret_is_never_rewritten() {
let token = format!("ghp_{}", "a1B2c3D4e5".repeat(4));
let text = format!("The key is {token} on `a.example`.");
let content = format!("- {text}\n");
let err = build(&content, [1, 1], &text, AT, REWRITE).unwrap_err();
assert!(err.contains("secret"), "{err}");
}
}