use recall_hooks::{backfill, exit, BackfillOutcome as Outcome, Disposition, Entry};
use crate::project;
use crate::ui::{self, Tone};
pub async fn run() -> anyhow::Result<i32> {
let ctx = project::resolve().hook_context()?;
let outcome = match backfill(&ctx).await {
Ok(outcome) => outcome,
Err(err) => {
eprintln!("recall: {err}");
return Ok(match err {
recall_hooks::Error::Pull { .. } => exit::SERVER,
_ => exit::CONFIG,
});
}
};
ui::title("recall backfill", ctx.project_key());
print_groups(&outcome);
if let Some(reason) = &outcome.stopped {
anstream::eprintln!();
anstream::eprintln!(
"{} {}",
ui::toned(Tone::Bad, Tone::Bad.mark()),
ui::bold(&format!(
"Stopped {reason}. {} after it were not reached.",
files(outcome.not_reached)
))
);
anstream::eprintln!(
" {}",
ui::accent("→ recall backfill again: it sends only what the server is still missing")
);
return Ok(exit::SERVER);
}
if let Some(reason) = &outcome.baseline {
anstream::eprintln!();
anstream::eprintln!("{} {reason}", ui::toned(Tone::Warn, Tone::Warn.mark()));
}
let (tone, summary) = summary(&outcome);
ui::verdict(tone, &summary);
Ok(exit::OK)
}
fn print_groups(outcome: &Outcome) {
let n = |what| outcome.count(what);
let (sent, matches) = (n(Disposition::Sent), n(Disposition::Matches));
if left(outcome) == 0 && n(Disposition::Internal) == 0 {
return;
}
anstream::println!();
if sent > 0 {
ui::step(Tone::Good, &format!("Sent {}", files(sent)), None);
}
if matches > 0 {
ui::step(
Tone::Good,
&format!("{} already on the server", files(matches)),
None,
);
}
group(
&outcome.entries,
Disposition::Held,
Tone::Warn,
"held back, as the server has different content and a backfill never overwrites",
Some(
"edit one through Claude and the server merges the two; deleting your copy \
deletes the server's too",
),
);
group(
&outcome.entries,
Disposition::Deleted,
Tone::Warn,
"deleted on the server, on another machine",
Some("recall pull catches this machine up"),
);
group(
&outcome.entries,
Disposition::Refused,
Tone::Bad,
"refused by the server, so skipped",
Some("fix what each one says, then recall backfill again"),
);
group(
&outcome.entries,
Disposition::Unroutable,
Tone::Warn,
"in no scope, so with nowhere to go",
Some("do what each one says, then recall backfill again"),
);
group(
&outcome.entries,
Disposition::Ignored,
Tone::Bad,
"on disk but unreadable",
Some("fix what each one says, then recall backfill again"),
);
group(
&outcome.entries,
Disposition::NotUtf8,
Tone::Quiet,
"not text, which cannot be synced",
None,
);
let internal = n(Disposition::Internal);
if internal > 0 {
ui::step(
Tone::Quiet,
&format!(
"{} of Recall's own, half-written, left alone",
files(internal)
),
None,
);
}
}
fn group(entries: &[Entry], what: Disposition, tone: Tone, why: &str, next: Option<&str>) {
let named: Vec<&Entry> = entries.iter().filter(|e| e.disposition == what).collect();
if named.is_empty() {
return;
}
ui::step(tone, &format!("{} {why}", files(named.len())), None);
for entry in named {
let line = match &entry.detail {
Some(detail) => format!("{}: {detail}", entry.path),
None => entry.path.clone(),
};
anstream::println!(" {}", ui::dim(&line));
}
if let Some(next) = next {
anstream::println!(" {}", ui::accent(&format!("→ {next}")));
}
}
fn left(outcome: &Outcome) -> usize {
let n = |what| outcome.count(what);
outcome.entries.len()
- n(Disposition::Sent)
- n(Disposition::Matches)
- n(Disposition::Internal)
}
fn summary(outcome: &Outcome) -> (Tone, String) {
let sent = outcome.count(Disposition::Sent);
let matches = outcome.count(Disposition::Matches);
match (sent, matches, left(outcome)) {
(0, 0, 0) => (
Tone::Quiet,
"Nothing to send: there are no memory files here yet.".to_string(),
),
(0, m, 0) => (
Tone::Good,
format!(
"Nothing to send: the server already has {}.",
match m {
1 => "the one file".to_string(),
m => format!("all {m} files"),
}
),
),
(1, 0, 0) => (Tone::Good, "Sent the one file here.".to_string()),
(s, 0, 0) => (Tone::Good, format!("Sent all {s} files here.")),
(s, m, 0) => (
Tone::Good,
format!("Sent {}. The server has all {} now.", files(s), s + m),
),
(0, _, l) => (
Tone::Warn,
format!("Nothing sent. {} left behind, listed above.", files(l)),
),
(s, _, l) => (
Tone::Warn,
format!("Sent {}. {} left behind, listed above.", files(s), files(l)),
),
}
}
fn files(n: usize) -> String {
match n {
1 => "1 file".to_string(),
n => format!("{n} files"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn outcome(of: &[Disposition]) -> Outcome {
Outcome {
entries: of
.iter()
.enumerate()
.map(|(i, d)| Entry {
path: format!("{i}.md"),
disposition: *d,
detail: None,
})
.collect(),
..Default::default()
}
}
#[test]
fn the_summary_says_zero_only_when_zero_is_the_answer() {
use Disposition::*;
let said = |of: &[Disposition]| summary(&outcome(of)).1;
assert_eq!(
said(&[]),
"Nothing to send: there are no memory files here yet."
);
assert_eq!(
said(&[Matches, Matches, Matches]),
"Nothing to send: the server already has all 3 files."
);
assert_eq!(said(&[Sent, Sent]), "Sent all 2 files here.");
assert_eq!(
said(&[Sent, Matches]),
"Sent 1 file. The server has all 2 now."
);
assert_eq!(
said(&[Sent, Held, Deleted, Internal]),
"Sent 1 file. 2 files left behind, listed above."
);
for s in [said(&[Matches]), said(&[Sent, Held])] {
assert!(!s.contains(" 0 "), "{s}");
}
assert_eq!(summary(&outcome(&[Sent, Held])).0, Tone::Warn);
}
}