use std::path::Path;
use anyhow::{Context, Result};
pub const MAX_RETRIES: u32 = 5;
pub const INITIAL_BACKOFF_MS: u64 = 500;
pub async fn backoff_on_429(
response: &reqwest::Response,
attempt: &mut u32,
backoff_ms: &mut u64,
) -> bool {
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS && *attempt < MAX_RETRIES {
*attempt += 1;
eprintln!(
" rate limited, retrying in {}ms (attempt {}/{})",
backoff_ms, *attempt, MAX_RETRIES,
);
tokio::time::sleep(std::time::Duration::from_millis(*backoff_ms)).await;
*backoff_ms *= 2;
true
} else {
false
}
}
pub async fn send_with_retry<F>(mut build: F) -> reqwest::Result<reqwest::Response>
where
F: FnMut() -> reqwest::RequestBuilder,
{
let mut attempt = 0u32;
let mut backoff_ms = INITIAL_BACKOFF_MS;
loop {
match build().send().await {
Ok(r) if backoff_on_429(&r, &mut attempt, &mut backoff_ms).await => continue,
other => break other,
}
}
}
pub fn is_interactive() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal() && std::env::var("CI").is_err()
}
pub fn detect_git_remote(root: &Path) -> Option<String> {
let get_url = |remote: &str| -> Option<String> {
let out = std::process::Command::new("git")
.args(["remote", "get-url", remote])
.current_dir(root)
.output()
.ok()?;
if out.status.success() {
let url = String::from_utf8(out.stdout).ok()?.trim().to_string();
if !url.is_empty() {
Some(url)
} else {
None
}
} else {
None
}
};
if let Some(url) = get_url("origin") {
return Some(url);
}
let out = std::process::Command::new("git")
.arg("remote")
.current_dir(root)
.output()
.ok()?;
if out.status.success() {
let remotes = String::from_utf8(out.stdout).ok()?;
let first = remotes.lines().next()?.trim().to_string();
if !first.is_empty() {
return get_url(&first);
}
}
None
}
pub fn prompt_and_persist_source_id(
config_path: &Path,
suggestion: Option<&str>,
) -> Result<String> {
use std::io::Write;
eprintln!();
eprintln!("Warning: '.lekton.yml' is missing the required 'id' field.");
eprintln!();
eprintln!("This ID uniquely identifies this repository as a document source.");
eprintln!("It scopes auto-archiving so multiple repos can share the same token");
eprintln!("without interfering with each other.");
eprintln!();
match suggestion {
Some(s) => eprint!("Enter a source ID [{}]: ", s),
None => eprint!("Enter a source ID (e.g. \"my-org/my-repo\", \"user-service\"): "),
}
std::io::stderr().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
let id = match (trimmed.is_empty(), suggestion) {
(true, Some(s)) => s.to_string(),
(true, None) => anyhow::bail!("Source ID cannot be empty."),
(false, _) => trimmed.to_string(),
};
let existing = if config_path.exists() {
std::fs::read_to_string(config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?
} else {
String::new()
};
let new_content = format!("id: {id}\n{existing}");
std::fs::write(config_path, &new_content)
.with_context(|| format!("Failed to write {}", config_path.display()))?;
eprintln!("Saved 'id: {id}' to {}", config_path.display());
eprintln!();
Ok(id)
}