use std::io::{BufRead, Write};
use std::path::PathBuf;
use std::time::Duration;
use clap::{Parser, Subcommand};
mod client;
mod extract;
mod walk;
#[derive(Parser)]
#[command(
name = "memcan",
about = "MemCan thin CLI client",
version = env!("CARGO_PKG_VERSION"),
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Add(AddArgs),
Search(SearchArgs),
Extract,
Status(StatusArgs),
Count(CountArgs),
IndexStandards(IndexStandardsArgs),
Export(ExportArgs),
Import(ImportArgs),
IndexCode(IndexCodeArgs),
}
#[derive(Parser)]
struct AddArgs {
memory: String,
#[arg(long)]
project: Option<String>,
}
#[derive(Parser)]
struct SearchArgs {
query: String,
#[arg(long)]
project: Option<String>,
#[arg(long, default_value = "10")]
limit: u32,
}
#[derive(Parser)]
struct StatusArgs {
operation_id: Option<String>,
}
#[derive(Parser)]
struct CountArgs {
#[arg(long)]
project: Option<String>,
}
#[derive(Parser)]
struct IndexStandardsArgs {
file: Option<PathBuf>,
#[arg(long)]
standard_id: String,
#[arg(long)]
standard_type: Option<String>,
#[arg(long)]
version: Option<String>,
#[arg(long)]
lang: Option<String>,
#[arg(long)]
url: Option<String>,
#[arg(long)]
drop: bool,
#[arg(long)]
wait: bool,
}
#[derive(Parser)]
struct ExportArgs {
collection: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
filter: Option<String>,
#[arg(long, default_value = "1000", value_parser = parse_page_size)]
page_size: u32,
}
#[derive(Parser)]
struct ImportArgs {
file: PathBuf,
#[arg(long, default_value = "50", value_parser = parse_batch_size)]
batch_size: usize,
}
#[derive(Parser)]
struct IndexCodeArgs {
dir: PathBuf,
#[arg(long)]
project: String,
#[arg(long, value_parser = parse_tech_stack)]
tech_stack: Option<String>,
#[arg(long, default_value = "1048576")]
max_file_size: u64,
#[arg(long, default_value = "10", value_parser = parse_batch_size)]
batch_size: usize,
#[arg(long)]
wait: bool,
}
fn parse_tech_stack(s: &str) -> Result<String, String> {
walk::canonical_tech_stack(s)
.map(str::to_string)
.ok_or_else(|| {
format!(
"unknown tech stack '{s}'; supported: {}",
walk::SUPPORTED_TECH_STACKS.join(", ")
)
})
}
fn parse_batch_size(s: &str) -> Result<usize, String> {
let n: usize = s
.parse()
.map_err(|_| format!("'{s}' is not a valid number"))?;
if n == 0 {
return Err("batch_size must be >= 1".to_string());
}
Ok(n)
}
fn parse_page_size(s: &str) -> Result<u32, String> {
let n: u32 = s
.parse()
.map_err(|_| format!("'{s}' is not a valid number"))?;
if n == 0 {
return Err("page_size must be >= 1".to_string());
}
Ok(n)
}
pub struct CliConfig {
pub url: String,
pub api_key: Option<String>,
}
fn setup_logging() -> tracing_appender::non_blocking::WorkerGuard {
let log_dir = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
.join(".claude")
.join("logs");
let _ = std::fs::create_dir_all(&log_dir);
let file_appender = tracing_appender::rolling::never(&log_dir, "memcan.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt()
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true)
.init();
guard
}
async fn send_import_batch(client: &client::McpClient, batch: &[String]) -> (u64, u64) {
let records = batch.join("\n");
match client
.call_tool("_import_records", serde_json::json!({ "records": records }))
.await
{
Ok(result) => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
let imported = parsed.get("imported").and_then(|v| v.as_u64()).unwrap_or(0);
let error_arr = parsed.get("errors").and_then(|v| v.as_array());
let errors = error_arr.map(|a| a.len() as u64).unwrap_or(0);
if let Some(arr) = error_arr {
for err in arr {
if let Some(msg) = err.as_str() {
eprintln!(" import error: {msg}");
}
}
}
(imported, errors)
} else {
(0, batch.len() as u64)
}
}
Err(e) => {
eprintln!("Error importing batch: {e}");
(0, batch.len() as u64)
}
}
}
const PACING_THRESHOLD: u64 = 16;
const BACKOFF_MAX: Duration = Duration::from_secs(30);
const RETRY_BUDGET: Duration = Duration::from_secs(900);
trait ToolCaller {
async fn call_tool(&self, name: &str, arguments: serde_json::Value) -> Result<String, String>;
}
impl ToolCaller for client::McpClient {
async fn call_tool(&self, name: &str, arguments: serde_json::Value) -> Result<String, String> {
client::McpClient::call_tool(self, name, arguments)
.await
.map_err(|e| e.to_string())
}
}
fn is_server_busy(err: &str) -> bool {
err.contains("Server busy") && err.contains("operations pending")
}
fn backoff_delay(attempt: u32) -> Duration {
let secs = 1u64.checked_shl(attempt).unwrap_or(u64::MAX);
Duration::from_secs(secs).min(BACKOFF_MAX)
}
async fn pending_tasks(client: &impl ToolCaller) -> Option<u64> {
let result = client
.call_tool("get_queue_status", serde_json::json!({}))
.await
.ok()?;
serde_json::from_str::<serde_json::Value>(&result)
.ok()?
.get("pending_tasks")
.and_then(|v| v.as_u64())
}
const WAIT_PROGRESS_INTERVAL: Duration = Duration::from_secs(15);
async fn wait_for_capacity(client: &impl ToolCaller) {
let start = tokio::time::Instant::now();
let mut last_progress: Option<tokio::time::Instant> = None;
loop {
match pending_tasks(client).await {
Some(n) if n >= PACING_THRESHOLD => {
if start.elapsed() >= RETRY_BUDGET {
eprintln!(
" server still saturated ({n} pending) after {}s; proceeding best-effort",
RETRY_BUDGET.as_secs()
);
return;
}
if last_progress.is_none_or(|t| t.elapsed() >= WAIT_PROGRESS_INTERVAL) {
eprintln!(" waiting for capacity ({n} operations pending)...");
last_progress = Some(tokio::time::Instant::now());
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
_ => return,
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum SubmitOutcome {
Tracked(String),
AcceptedUntracked,
}
async fn submit_batch_with_retry(
client: &impl ToolCaller,
tool_args: &serde_json::Value,
batch_label: &str,
) -> Result<SubmitOutcome, String> {
let start = tokio::time::Instant::now();
let mut attempt: u32 = 0;
loop {
match client
.call_tool("index_code_files", tool_args.clone())
.await
{
Ok(result) => {
let op_id = serde_json::from_str::<serde_json::Value>(&result)
.ok()
.and_then(|v| {
v.get("operation_id")
.and_then(|o| o.as_str())
.map(str::to_string)
});
return Ok(match op_id {
Some(id) => SubmitOutcome::Tracked(id),
None => SubmitOutcome::AcceptedUntracked,
});
}
Err(msg) => {
if !is_server_busy(&msg) {
return Err(format!("{batch_label}: {msg}"));
}
if start.elapsed() >= RETRY_BUDGET {
return Err(format!(
"{batch_label}: still rejected after {}s of retries (server busy); aborting to avoid dropping it",
RETRY_BUDGET.as_secs()
));
}
let delay = backoff_delay(attempt);
eprintln!(
" {batch_label}: server busy, retrying in {}s",
delay.as_secs()
);
tokio::time::sleep(delay).await;
attempt = attempt.saturating_add(1);
}
}
}
}
async fn poll_until_done(client: &client::McpClient, operation_id: &str) {
loop {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
match client
.call_tool(
"get_queue_status",
serde_json::json!({ "operation_id": operation_id }),
)
.await
{
Ok(result) => {
if let Ok(status) = serde_json::from_str::<serde_json::Value>(&result) {
let step = status
.get("step")
.or_else(|| status.get("status"))
.and_then(|v| v.as_str())
.unwrap_or("");
if step == "completed" || step == "completed_degraded" || step == "failed" {
if step == "failed" {
eprintln!("Operation {operation_id} failed");
}
break;
}
eprint!("\r {operation_id}: {step}");
}
}
Err(e) => {
eprintln!("Error polling {operation_id}: {e}");
break;
}
}
}
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
match cli.command {
Command::Extract => {
let _log_guard = setup_logging();
if let Err(e) = extract::run().await {
tracing::error!(error = %e, "extract hook failed");
}
}
Command::Add(args) => {
let config = client::load_config();
match client::McpClient::connect(&config).await {
Ok(c) => {
let mut tool_args = serde_json::json!({"memory": args.memory});
if let Some(ref p) = args.project {
tool_args["project"] = serde_json::Value::String(p.clone());
}
match c.call_tool("add_memory", tool_args).await {
Ok(result) => println!("{result}"),
Err(e) => eprintln!("Error: {e}"),
}
c.close().await;
}
Err(e) => eprintln!("Connection failed: {e}"),
}
}
Command::Search(args) => {
let config = client::load_config();
match client::McpClient::connect(&config).await {
Ok(c) => {
let mut tool_args = serde_json::json!({
"query": args.query,
"limit": args.limit,
});
if let Some(ref p) = args.project {
tool_args["project"] = serde_json::Value::String(p.clone());
}
match c.call_tool("search_memories", tool_args).await {
Ok(result) => println!("{result}"),
Err(e) => eprintln!("Error: {e}"),
}
c.close().await;
}
Err(e) => eprintln!("Connection failed: {e}"),
}
}
Command::Status(args) => {
let config = client::load_config();
match client::McpClient::connect(&config).await {
Ok(c) => {
let mut tool_args = serde_json::json!({});
if let Some(ref id) = args.operation_id {
tool_args["operation_id"] = serde_json::Value::String(id.clone());
}
match c.call_tool("get_queue_status", tool_args).await {
Ok(result) => println!("{result}"),
Err(e) => eprintln!("Error: {e}"),
}
c.close().await;
}
Err(e) => eprintln!("Connection failed: {e}"),
}
}
Command::Count(args) => {
let config = client::load_config();
match client::McpClient::connect(&config).await {
Ok(c) => {
let mut tool_args = serde_json::json!({});
if let Some(ref p) = args.project {
tool_args["project"] = serde_json::Value::String(p.clone());
}
match c.call_tool("count_memories", tool_args).await {
Ok(result) => println!("{result}"),
Err(e) => eprintln!("Error: {e}"),
}
c.close().await;
}
Err(e) => eprintln!("Connection failed: {e}"),
}
}
Command::Export(args) => {
let config = client::load_config();
match client::McpClient::connect(&config).await {
Ok(c) => {
let mut writer: Box<dyn Write> = match &args.output {
Some(path) => match std::fs::File::create(path) {
Ok(f) => Box::new(std::io::BufWriter::new(f)),
Err(e) => {
eprintln!("Error creating output file: {e}");
std::process::exit(1);
}
},
None => Box::new(std::io::stdout().lock()),
};
let mut offset: u32 = 0;
let mut total: u32 = 0;
loop {
let mut tool_args = serde_json::json!({
"collection": args.collection,
"limit": args.page_size,
"offset": offset,
});
if let Some(ref f) = args.filter {
tool_args["filter"] = serde_json::Value::String(f.clone());
}
match c.call_tool("export_collection", tool_args).await {
Ok(result) => {
let parsed: serde_json::Value = match serde_json::from_str(&result)
{
Ok(v) => v,
Err(e) => {
eprintln!("Error parsing server response: {e}");
std::process::exit(1);
}
};
let data =
parsed.get("data").and_then(|v| v.as_str()).unwrap_or("");
let count =
parsed.get("count").and_then(|v| v.as_u64()).unwrap_or(0)
as u32;
if !data.is_empty()
&& let Err(e) = writeln!(writer, "{data}")
{
eprintln!("Error writing output: {e}");
std::process::exit(1);
}
total += count;
if count < args.page_size {
break;
}
offset += args.page_size;
}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
eprintln!("Exported {total} records from {}", args.collection);
c.close().await;
}
Err(e) => eprintln!("Connection failed: {e}"),
}
}
Command::Import(args) => {
let config = client::load_config();
let file = match std::fs::File::open(&args.file) {
Ok(f) => f,
Err(e) => {
eprintln!("Error opening file: {e}");
std::process::exit(1);
}
};
let reader = std::io::BufReader::new(file);
match client::McpClient::connect(&config).await {
Ok(c) => {
let mut total_imported: u64 = 0;
let mut total_errors: u64 = 0;
let mut batch: Vec<String> = Vec::with_capacity(args.batch_size);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
eprintln!("Error reading line: {e}");
total_errors += 1;
continue;
}
};
if line.trim().is_empty() {
continue;
}
batch.push(line);
if batch.len() >= args.batch_size {
let (imported, errors) = send_import_batch(&c, &batch).await;
total_imported += imported;
total_errors += errors;
batch.clear();
}
}
if !batch.is_empty() {
let (imported, errors) = send_import_batch(&c, &batch).await;
total_imported += imported;
total_errors += errors;
}
eprintln!("Imported {total_imported} records ({total_errors} errors)");
c.close().await;
}
Err(e) => eprintln!("Connection failed: {e}"),
}
}
Command::IndexCode(args) => {
let config = client::load_config();
let opts = walk::WalkOptions {
max_file_size: args.max_file_size,
tech_stack: args.tech_stack.as_deref(),
};
let files = match walk::walk_directory(&args.dir, &opts) {
Ok(f) => f,
Err(e) => {
eprintln!("Error walking directory: {e}");
std::process::exit(1);
}
};
if files.is_empty() {
eprintln!("No supported files found in {}", args.dir.display());
std::process::exit(1);
}
let tech_stack = match args.tech_stack {
Some(ts) => ts,
None => match walk::detect_tech_stack(&files) {
Ok(ts) => {
eprintln!("Auto-detected tech stack: {ts}");
ts
}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
},
};
let batches = walk::chunk_into_batches(files, args.batch_size);
let total_files: usize = batches.iter().map(|b| b.len()).sum();
eprintln!("Indexing {total_files} files in {} batches", batches.len());
match client::McpClient::connect(&config).await {
Ok(c) => {
let mut operation_ids: Vec<String> = Vec::new();
let total_batches = batches.len();
for (i, batch) in batches.iter().enumerate() {
let files_json: Vec<serde_json::Value> = batch
.iter()
.map(|f| {
serde_json::json!({
"path": f.relative_path,
"content": f.content,
})
})
.collect();
let tool_args = serde_json::json!({
"files": files_json,
"project": args.project,
"tech_stack": tech_stack,
});
let batch_label = format!("Batch {}/{}", i + 1, total_batches);
wait_for_capacity(&c).await;
match submit_batch_with_retry(&c, &tool_args, &batch_label).await {
Ok(SubmitOutcome::Tracked(op_id)) => {
operation_ids.push(op_id);
eprintln!(" {batch_label}: {} files submitted", batch.len());
}
Ok(SubmitOutcome::AcceptedUntracked) => {
eprintln!(
" {batch_label}: {} files accepted (no operation_id; \
cannot poll for completion)",
batch.len()
);
}
Err(e) => {
eprintln!("Error: {e}");
eprintln!(
"Aborting: {} of {} batches submitted before failure. \
No batch was silently dropped; re-run to finish (indexing is idempotent).",
i, total_batches
);
c.close().await;
std::process::exit(1);
}
}
}
if args.wait && !operation_ids.is_empty() {
eprintln!(
"Waiting for {} operations to complete...",
operation_ids.len()
);
for op_id in &operation_ids {
poll_until_done(&c, op_id).await;
}
}
eprintln!(
"Submitted {total_files} files ({} operations)",
operation_ids.len()
);
c.close().await;
}
Err(e) => {
eprintln!("Connection failed: {e}");
std::process::exit(1);
}
}
}
Command::IndexStandards(args) => {
let config = client::load_config();
match client::McpClient::connect(&config).await {
Ok(c) => {
if args.drop {
match c
.call_tool(
"drop_indexed_standards",
serde_json::json!({ "standard_id": args.standard_id }),
)
.await
{
Ok(result) => println!("{result}"),
Err(e) => eprintln!("Error: {e}"),
}
c.close().await;
return;
}
let file = match args.file.as_ref() {
Some(f) => f,
None => {
eprintln!("Error: file is required unless --drop");
std::process::exit(1);
}
};
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
eprintln!("Error reading file: {e}");
std::process::exit(1);
}
};
let standard_type = match args.standard_type.as_deref() {
Some(t) => t,
None => {
eprintln!("Error: --standard-type is required for indexing");
std::process::exit(1);
}
};
let mut tool_args = serde_json::json!({
"content": content,
"standard_id": args.standard_id,
"standard_type": standard_type,
});
if let Some(ref v) = args.version {
tool_args["version"] = serde_json::json!(v);
}
if let Some(ref l) = args.lang {
tool_args["lang"] = serde_json::json!(l);
}
if let Some(ref u) = args.url {
tool_args["url"] = serde_json::json!(u);
}
match c.call_tool("index_standards", tool_args).await {
Ok(result) => {
if args.wait {
if let Ok(parsed) =
serde_json::from_str::<serde_json::Value>(&result)
{
if let Some(op_id) =
parsed.get("operation_id").and_then(|v| v.as_str())
{
loop {
tokio::time::sleep(std::time::Duration::from_secs(2))
.await;
match c
.call_tool(
"get_queue_status",
serde_json::json!({
"operation_id": op_id,
}),
)
.await
{
Ok(status_result) => {
if let Ok(status) =
serde_json::from_str::<serde_json::Value>(
&status_result,
)
{
let step = status
.get("step")
.or_else(|| status.get("status"))
.and_then(|v| v.as_str())
.unwrap_or("");
if step == "completed"
|| step == "completed_degraded"
|| step == "failed"
{
println!(
"{}",
serde_json::to_string_pretty(
&status
)
.unwrap_or(status_result)
);
if step == "failed" {
c.close().await;
std::process::exit(1);
}
break;
}
eprint!("\r{step}");
}
}
Err(e) => {
eprintln!("Error polling status: {e}");
break;
}
}
}
} else {
println!("{result}");
}
} else {
println!("{result}");
}
} else {
println!("{result}");
}
}
Err(e) => eprintln!("Error: {e}"),
}
c.close().await;
}
Err(e) => eprintln!("Connection failed: {e}"),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_server_busy_detects_rejection() {
assert!(is_server_busy(
"Mcp error: -32603: Server busy: 20 operations pending (max 20). Try again later."
));
}
#[test]
fn is_server_busy_ignores_other_errors() {
assert!(!is_server_busy(
"Mcp error: -32603: embedding model not found"
));
assert!(!is_server_busy("Connection refused"));
assert!(!is_server_busy(""));
}
#[test]
fn backoff_delay_is_exponential_then_capped() {
assert_eq!(backoff_delay(0), Duration::from_secs(1));
assert_eq!(backoff_delay(1), Duration::from_secs(2));
assert_eq!(backoff_delay(2), Duration::from_secs(4));
assert_eq!(backoff_delay(3), Duration::from_secs(8));
assert_eq!(backoff_delay(4), Duration::from_secs(16));
assert_eq!(backoff_delay(5), BACKOFF_MAX);
}
#[test]
fn backoff_delay_never_exceeds_cap_for_large_attempts() {
assert_eq!(backoff_delay(63), BACKOFF_MAX);
assert_eq!(backoff_delay(u32::MAX), BACKOFF_MAX);
}
#[test]
fn parse_tech_stack_canonicalizes_to_lowercase() {
assert_eq!(parse_tech_stack("Rust").unwrap(), "rust");
assert_eq!(parse_tech_stack("PYTHON").unwrap(), "python");
assert_eq!(parse_tech_stack("go").unwrap(), "go");
assert_eq!(parse_tech_stack("TypeScript").unwrap(), "typescript");
}
#[test]
fn parse_tech_stack_rejects_unknown() {
let err = parse_tech_stack("rsut").unwrap_err();
assert!(err.contains("unknown tech stack 'rsut'"), "got: {err}");
assert!(err.contains("rust, python, go, typescript"), "got: {err}");
}
const _: () = assert!(PACING_THRESHOLD < 20);
use std::cell::RefCell;
use std::collections::VecDeque;
const BUSY: &str = "Mcp error: -32603: Server busy: 20 operations pending (max 20).";
struct MockCaller {
responses: RefCell<VecDeque<Result<String, String>>>,
sticky: RefCell<Option<Result<String, String>>>,
calls: RefCell<usize>,
}
impl MockCaller {
fn scripted(responses: Vec<Result<String, String>>) -> Self {
Self {
responses: RefCell::new(responses.into()),
sticky: RefCell::new(None),
calls: RefCell::new(0),
}
}
fn always(result: Result<String, String>) -> Self {
Self {
responses: RefCell::new(VecDeque::new()),
sticky: RefCell::new(Some(result)),
calls: RefCell::new(0),
}
}
fn call_count(&self) -> usize {
*self.calls.borrow()
}
}
impl ToolCaller for MockCaller {
async fn call_tool(
&self,
_name: &str,
_arguments: serde_json::Value,
) -> Result<String, String> {
*self.calls.borrow_mut() += 1;
if let Some(next) = self.responses.borrow_mut().pop_front() {
return next;
}
self.sticky
.borrow()
.clone()
.unwrap_or_else(|| Err("no scripted response".into()))
}
}
fn ok_op(id: &str) -> Result<String, String> {
Ok(format!(r#"{{"operation_id":"{id}"}}"#))
}
#[tokio::test(start_paused = true)]
async fn submit_retries_on_busy_then_succeeds() {
let caller =
MockCaller::scripted(vec![Err(BUSY.into()), Err(BUSY.into()), ok_op("op-123")]);
let args = serde_json::json!({});
let result = submit_batch_with_retry(&caller, &args, "Batch 1/5").await;
assert_eq!(result.unwrap(), SubmitOutcome::Tracked("op-123".into()));
assert_eq!(caller.call_count(), 3, "two busy retries then success");
}
#[tokio::test(start_paused = true)]
async fn submit_returns_loud_err_on_non_busy_error() {
let caller = MockCaller::scripted(vec![Err("embedding model not found".into())]);
let args = serde_json::json!({});
let err = submit_batch_with_retry(&caller, &args, "Batch 2/5")
.await
.unwrap_err();
assert!(err.contains("Batch 2/5"), "names the batch: {err}");
assert!(err.contains("embedding model not found"));
assert_eq!(caller.call_count(), 1, "non-busy errors are not retried");
}
#[tokio::test(start_paused = true)]
async fn submit_fails_loudly_when_budget_exhausted() {
let caller = MockCaller::always(Err(BUSY.into()));
let args = serde_json::json!({});
let err = submit_batch_with_retry(&caller, &args, "Batch 3/5")
.await
.unwrap_err();
assert!(err.contains("Batch 3/5"), "names the batch: {err}");
assert!(err.contains("server busy"));
assert!(caller.call_count() > 1, "retried before giving up");
}
#[tokio::test(start_paused = true)]
async fn submit_accepted_untracked_when_no_operation_id() {
let caller = MockCaller::scripted(vec![Ok(r#"{"status":"queued"}"#.into())]);
let args = serde_json::json!({});
let outcome = submit_batch_with_retry(&caller, &args, "Batch 4/5")
.await
.unwrap();
assert_eq!(outcome, SubmitOutcome::AcceptedUntracked);
}
#[tokio::test(start_paused = true)]
async fn wait_for_capacity_returns_when_below_threshold() {
let caller = MockCaller::scripted(vec![
Ok(r#"{"pending_tasks":18}"#.into()),
Ok(r#"{"pending_tasks":5}"#.into()),
]);
wait_for_capacity(&caller).await;
assert_eq!(caller.call_count(), 2, "waited once, then proceeded");
}
#[tokio::test(start_paused = true)]
async fn wait_for_capacity_returns_after_budget_when_saturated() {
let caller = MockCaller::always(Ok(r#"{"pending_tasks":18}"#.into()));
let start = tokio::time::Instant::now();
wait_for_capacity(&caller).await;
assert!(
start.elapsed() >= RETRY_BUDGET,
"must wait at least the full budget before giving up"
);
assert!(
caller.call_count() > 1,
"polled repeatedly before proceeding best-effort"
);
}
#[tokio::test(start_paused = true)]
async fn wait_for_capacity_returns_immediately_on_error() {
let caller = MockCaller::always(Err("connection refused".into()));
wait_for_capacity(&caller).await;
assert_eq!(caller.call_count(), 1, "unreadable count must not hang");
}
#[tokio::test(start_paused = true)]
async fn wait_for_capacity_returns_on_missing_field() {
let caller = MockCaller::always(Ok(r#"{"operations":[]}"#.into()));
wait_for_capacity(&caller).await;
assert_eq!(
caller.call_count(),
1,
"missing pending_tasks must not hang"
);
}
}