use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
use recall_hooks::exit;
use recall_wire::SuggestedEdit;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Stop {
Refused { what: String, then: String },
Cancelled,
}
pub(crate) fn refused(what: impl Into<String>, then: impl Into<String>) -> Stop {
Stop::Refused {
what: what.into(),
then: then.into(),
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Hints {
pub rerun: &'static str,
pub again: &'static str,
}
pub(crate) fn lines(f: &[u32; 2]) -> String {
if f[0] == f[1] {
format!("line {}", f[0])
} else {
format!("lines {}-{}", f[0], f[1])
}
}
pub(crate) fn printable(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_control() && c != '\t' && c != '\n' {
' '
} else {
c
}
})
.collect()
}
pub(crate) fn indented(text: &str) -> String {
text.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn local_path(
ctx: &recall_hooks::Context,
edit: &SuggestedEdit,
) -> Result<PathBuf, Stop> {
let Some(scope) = ctx.scopes.iter().find(|s| s.key == edit.project_key) else {
let here: Vec<&str> = ctx.scopes.iter().map(|s| s.key.as_str()).collect();
return Err(refused(
format!(
"the edit is to {} in {}, which is not synced here (this directory syncs {}).",
edit.file_path,
edit.project_key,
here.join(", ")
),
"Run it from a checkout of that project, or on a machine that syncs that scope.",
));
};
if recall_wire::validate_file_path(&edit.file_path).is_err() {
return Err(refused(
format!("{} is not a path Recall syncs.", edit.file_path),
"",
));
}
let mut path = ctx.memory_dir.clone();
if let Some(prefix) = &scope.prefix {
path.push(prefix);
}
for part in edit.file_path.split('/') {
path.push(part);
}
Ok(path)
}
pub(crate) fn confirmed(question: &str, yes: bool) -> Result<bool, Stop> {
if yes {
return Ok(true);
}
if !(io::stdin().is_terminal() && io::stderr().is_terminal()) {
return Err(refused(
"needs a terminal to ask first.",
"In a script, pass --yes.",
));
}
cliclack::confirm(question)
.initial_value(false)
.interact()
.map_err(|_| Stop::Cancelled)
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Made {
Written,
Declined,
}
pub(crate) fn make_edit(
path: &Path,
edit: &SuggestedEdit,
hints: Hints,
confirm: impl FnOnce(&str) -> Result<bool, Stop>,
) -> Result<Made, Stop> {
let read = |path: &Path| {
std::fs::read_to_string(path).map_err(|e| {
refused(
format!("cannot read {}: {e}", path.display()),
"Start a Claude Code session here first, so the file is on this machine.",
)
})
};
let local = read(path)?;
let changed = edit.apply_to(&local).map_err(|why| {
refused(
format!("{why}, here or on another machine, so the edit may no longer fit."),
format!("Pull, then make a new report: {}", hints.rerun),
)
})?;
let shown = if edit.replacement.is_empty() {
format!("Edit remove {}", lines(&edit.lines))
} else {
format!(
"Edit {} become:\n{}",
lines(&edit.lines),
indented(&printable(&edit.replacement))
)
};
if !confirm(&shown)? {
return Ok(Made::Declined);
}
if read(path)? != local {
return Err(refused(
format!(
"{} changed while you were asked, so the edit was not made: it would have \
replaced that change.",
path.display()
),
format!(
"Run {} again; if the report no longer fits, make a new one.",
hints.again
),
));
}
std::fs::write(path, changed)
.map_err(|e| refused(format!("cannot write {}: {e}", path.display()), ""))?;
Ok(Made::Written)
}
pub(crate) fn said(command: &str, stop: Stop) -> i32 {
if let Stop::Refused { what, then } = stop {
eprintln!("{command}: {what}");
if !then.is_empty() {
eprintln!(" {then}");
}
}
exit::CONFIG
}
pub(crate) async fn apply_and_push(
command: &str,
id: &str,
heading: &str,
edit: &SuggestedEdit,
hints: Hints,
yes: bool,
) -> i32 {
let ctx = match crate::project::resolve().hook_context() {
Ok(ctx) => ctx,
Err(e) => return said(command, refused(format!("{e:#}"), "")),
};
let path = match local_path(&ctx, edit) {
Ok(path) => path,
Err(stop) => return said(command, stop),
};
let question = format!("Make this edit to {} and push it?", edit.file_path);
let made = make_edit(&path, edit, hints, |shown| {
eprintln!("{heading}");
eprintln!("File {}", path.display());
eprintln!("{shown}");
confirmed(&question, yes)
});
match made {
Err(stop) => return said(command, stop),
Ok(Made::Declined) => {
eprintln!("Nothing was changed.");
return exit::CONFIG;
}
Ok(Made::Written) => {}
}
match recall_hooks::push(&ctx, &path).await {
Ok(_) => {
println!("Applied {id} to {} and pushed it.", path.display());
exit::OK
}
Err(e) => {
eprintln!("{command}: the edit is made here, but pushing it failed: {e}");
eprintln!(" The file is changed here, and goes with the next edit made to it.");
exit::SERVER
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const HINTS: Hints = Hints {
rerun: "recall eval run",
again: "recall eval apply",
};
fn edit_for(content: &str) -> SuggestedEdit {
SuggestedEdit {
project_key: "acme/app".into(),
file_path: "deploy.md".into(),
base_sha256: recall_wire::content_sha256(content),
lines: [2, 2],
replacement: "- key: [removed]\n".into(),
}
}
#[test]
fn an_edit_made_while_asking_is_not_written_over() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("deploy.md");
let content = "# Deploy\n- key: abc123\n";
std::fs::write(&path, content).unwrap();
let theirs = "# Deploy\n- key: abc123\n- a line a session just added\n";
let made = make_edit(&path, &edit_for(content), HINTS, |_| {
std::fs::write(&path, theirs).unwrap();
Ok(true)
});
assert!(matches!(made, Err(Stop::Refused { .. })), "{made:?}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), theirs);
}
#[test]
fn an_unchanged_file_gets_the_edit_and_a_no_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("deploy.md");
let content = "# Deploy\n- key: abc123\n";
std::fs::write(&path, content).unwrap();
assert_eq!(
make_edit(&path, &edit_for(content), HINTS, |_| Ok(false)).unwrap(),
Made::Declined
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
assert_eq!(
make_edit(&path, &edit_for(content), HINTS, |_| Ok(true)).unwrap(),
Made::Written
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"# Deploy\n- key: [removed]\n"
);
}
#[test]
fn an_edit_against_an_older_version_is_refused_before_asking() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("deploy.md");
std::fs::write(&path, "# Deploy\n- key: changed since\n").unwrap();
let made = make_edit(&path, &edit_for("# Deploy\n- key: abc123\n"), HINTS, |_| {
panic!("asked about an edit that no longer fits")
});
match made {
Err(Stop::Refused { then, .. }) => assert!(then.contains("recall eval run"), "{then}"),
other => panic!("{other:?}"),
}
}
}