use std::time::Duration;
use super::{FixArgs, fix_debug};
pub(super) const REVIEW_TIMEOUT_SECS: u64 = 300;
pub(super) const PREVIEW_REVIEW_TIMEOUT_SECS: u64 = 300;
pub(super) const REVIEW_TIMEOUT_RETRY_SECS: u64 = 900;
const MAX_REVIEW_TIMEOUT_SECS: u64 = 30 * 60;
const REVIEW_TIMEOUT_SCALE_CHUNK_BYTES: usize = 100_000;
const REVIEW_TIMEOUT_SCALE_INCREMENT_SECS: u64 = 60;
fn supported_agent_cli_on_path() -> Option<&'static str> {
supported_agent_cli_on_path_with(|cmd| which::which(cmd).is_ok())
}
pub(super) fn supported_agent_cli_on_path_with(
mut exists: impl FnMut(&str) -> bool,
) -> Option<&'static str> {
["claude", "codex", "gemini", "opencode"]
.into_iter()
.find(|cmd| exists(cmd))
}
pub(super) fn no_provider_configured_message() -> String {
"no LLM provider configured and no supported agent CLI found on PATH \
(looked for: claude, codex, gemini, opencode).\n\n \
Run `difflore providers setup` to choose a provider, or install one of the supported \
agent CLIs and retry."
.to_owned()
}
fn preview_no_provider_configured_message() -> String {
"no AI provider configured — run `difflore providers setup`.\n\n \
`difflore review` reports a real review only when a provider you configured \
actually runs; it will not silently fall back to an agent CLI found on PATH \
for the review's clean/at-risk verdict.\n \
(The apply path, `difflore fix`, still uses an installed agent CLI when no \
provider is configured.)"
.to_owned()
}
pub(super) fn preflight_decision(
has_active_provider: bool,
agent_cli: Option<&str>,
require_configured_provider: bool,
) -> Result<(), String> {
if has_active_provider {
return Ok(());
}
if require_configured_provider {
return Err(preview_no_provider_configured_message());
}
if let Some(cmd) = agent_cli {
fix_debug!("using agent CLI fallback `{cmd}` for provider mode");
return Ok(());
}
Err(no_provider_configured_message())
}
pub(super) async fn preflight_provider_backend(
db: &difflore_core::SqlitePool,
require_configured_provider: bool,
) -> Result<(), String> {
let providers = difflore_core::infra::providers::list(db)
.await
.map_err(|e| format!("failed to read provider configuration: {e}"))?;
let has_active_provider = providers.iter().any(|provider| provider.is_active);
let agent_cli = if has_active_provider || require_configured_provider {
None
} else {
supported_agent_cli_on_path()
};
preflight_decision(has_active_provider, agent_cli, require_configured_provider)
}
fn parse_review_timeout_override(raw: Option<&str>) -> Option<u64> {
let value = raw?.trim().parse::<u64>().ok()?;
(1..=MAX_REVIEW_TIMEOUT_SECS)
.contains(&value)
.then_some(value)
}
pub(super) fn review_timeout_for_args_with_env<'a>(
args: &FixArgs,
packed_diff_bytes: Option<usize>,
env_var: impl Fn(&'a str) -> Option<String>,
) -> Duration {
let override_secs = args.read_only.then(|| {
env_var(difflore_core::infra::env::DIFFLORE_FIX_PREVIEW_REVIEW_TIMEOUT_SECS)
.and_then(|value| parse_review_timeout_override(Some(&value)))
});
if args.preview {
Duration::from_secs(override_secs.flatten().unwrap_or_else(|| {
scaled_review_timeout_secs(PREVIEW_REVIEW_TIMEOUT_SECS, packed_diff_bytes)
}))
} else {
Duration::from_secs(
override_secs.flatten().unwrap_or_else(|| {
scaled_review_timeout_secs(REVIEW_TIMEOUT_SECS, packed_diff_bytes)
}),
)
}
}
fn scaled_review_timeout_secs(base_secs: u64, packed_diff_bytes: Option<usize>) -> u64 {
let Some(packed_diff_bytes) = packed_diff_bytes else {
return base_secs;
};
let additional_bytes = packed_diff_bytes.saturating_sub(REVIEW_TIMEOUT_SCALE_CHUNK_BYTES);
let additional_chunks = additional_bytes.saturating_add(REVIEW_TIMEOUT_SCALE_CHUNK_BYTES - 1)
/ REVIEW_TIMEOUT_SCALE_CHUNK_BYTES;
let additional_secs = u64::try_from(additional_chunks)
.unwrap_or(u64::MAX)
.saturating_mul(REVIEW_TIMEOUT_SCALE_INCREMENT_SECS);
base_secs
.saturating_add(additional_secs)
.min(REVIEW_TIMEOUT_RETRY_SECS)
}
pub(super) fn review_timeout_for_args(
args: &FixArgs,
packed_diff_bytes: Option<usize>,
) -> Duration {
review_timeout_for_args_with_env(args, packed_diff_bytes, difflore_core::infra::env::var)
}
pub(super) fn review_id_for_provider_run(review_id: Option<&str>, preview: bool) -> Option<String> {
if preview {
None
} else {
review_id.map(str::to_owned)
}
}
#[cfg(test)]
mod tests {
use super::super::FixAgentMode;
use super::*;
fn fix_args(preview: bool, json: bool) -> FixArgs {
FixArgs {
yes: false,
preview,
read_only: preview,
ci: false,
strict: false,
diff_scope: None,
pr: None,
repo: None,
base: None,
work_branch: None,
no_checkout: false,
allow_dirty: false,
no_upload_acceptance: false,
explain_rules: false,
report: None,
json,
path: None,
agent: FixAgentMode::Provider,
}
}
fn ci_review_args() -> FixArgs {
FixArgs {
read_only: true,
ci: true,
..fix_args(false, true)
}
}
#[test]
fn preview_review_timeout_accepts_env_override() {
let args = fix_args(true, true);
assert_eq!(
review_timeout_for_args_with_env(&args, None, |_| None),
Duration::from_secs(PREVIEW_REVIEW_TIMEOUT_SECS)
);
assert_eq!(
review_timeout_for_args_with_env(&args, None, |key| {
(key == difflore_core::infra::env::DIFFLORE_FIX_PREVIEW_REVIEW_TIMEOUT_SECS)
.then(|| "75".to_owned())
}),
Duration::from_secs(75)
);
assert_eq!(
review_timeout_for_args_with_env(&args, None, |_| Some("0".to_owned())),
Duration::from_secs(PREVIEW_REVIEW_TIMEOUT_SECS)
);
assert_eq!(
review_timeout_for_args_with_env(&args, None, |_| Some("not-a-number".to_owned())),
Duration::from_secs(PREVIEW_REVIEW_TIMEOUT_SECS)
);
}
#[test]
fn review_timeout_scales_with_packed_diff_size() {
let args = fix_args(true, true);
assert_eq!(
review_timeout_for_args_with_env(&args, Some(100_000), |_| None),
Duration::from_secs(PREVIEW_REVIEW_TIMEOUT_SECS)
);
assert_eq!(
review_timeout_for_args_with_env(&args, Some(100_001), |_| None),
Duration::from_secs(PREVIEW_REVIEW_TIMEOUT_SECS + 60)
);
assert_eq!(
review_timeout_for_args_with_env(&args, Some(1_500_000), |_| None),
Duration::from_secs(REVIEW_TIMEOUT_RETRY_SECS)
);
}
#[test]
fn ci_review_timeout_uses_same_default_and_env_override() {
let args = ci_review_args();
assert_eq!(
review_timeout_for_args_with_env(&args, None, |_| None),
Duration::from_secs(REVIEW_TIMEOUT_SECS)
);
assert_eq!(
review_timeout_for_args_with_env(&args, None, |key| {
(key == difflore_core::infra::env::DIFFLORE_FIX_PREVIEW_REVIEW_TIMEOUT_SECS)
.then(|| "420".to_owned())
}),
Duration::from_secs(420)
);
assert_eq!(
review_timeout_for_args_with_env(&args, None, |_| Some("0".to_owned())),
Duration::from_secs(REVIEW_TIMEOUT_SECS)
);
}
#[test]
fn preview_provider_run_does_not_attach_pr_review_id() {
assert_eq!(
review_id_for_provider_run(Some("github-pr:owner/repo#12"), true),
None
);
assert_eq!(
review_id_for_provider_run(Some("github-pr:owner/repo#12"), false).as_deref(),
Some("github-pr:owner/repo#12")
);
}
#[test]
fn preview_preflight_rejects_agent_cli_fallback_when_no_provider_configured() {
assert!(preflight_decision(false, Some("claude"), true).is_err());
assert!(preflight_decision(false, None, true).is_err());
assert!(preflight_decision(true, None, true).is_ok());
assert!(preflight_decision(true, Some("claude"), true).is_ok());
}
#[test]
fn apply_path_preflight_still_accepts_agent_cli_fallback() {
assert!(preflight_decision(true, None, false).is_ok());
assert!(preflight_decision(false, Some("codex"), false).is_ok());
assert!(preflight_decision(false, None, false).is_err());
}
#[test]
fn provider_preflight_uses_supported_agent_cli_order() {
assert_eq!(
supported_agent_cli_on_path_with(|cmd| cmd == "gemini" || cmd == "codex"),
Some("codex")
);
assert_eq!(supported_agent_cli_on_path_with(|_| false), None);
}
}