use super::support::{outcome, outcome_failing, provider_use, rendered, rendered_json};
use crate::analysis::result::{FailureReason, ProviderFailure};
use crate::cli::OutputFormat;
use crate::cli::check::{CheckOutcome, ProviderUse};
fn outcome_serving(uses: Vec<ProviderUse>) -> CheckOutcome {
CheckOutcome {
provider_uses: uses,
..outcome()
}
}
#[test]
fn a_run_served_by_the_head_prints_no_provider_block() {
let outcome = outcome_serving(vec![provider_use(
0,
"local",
"http://localhost:1234/v1",
4,
)]);
let text = rendered(&outcome, OutputFormat::Text);
assert_eq!(
text, "No issues found.\n",
"a run that never failed over must print exactly the clean message"
);
}
#[test]
fn a_run_that_fell_through_names_every_provider_that_served() {
let outcome = outcome_serving(vec![
provider_use(0, "local-model", "http://localhost:1234/v1", 4),
provider_use(1, "cloud-model", "https://api.example/v1", 12),
]);
let text = rendered(&outcome, OutputFormat::Text);
assert!(
text.contains("fell through"),
"the fallback must be announced, not silent:\n{text}"
);
assert!(
text.contains("2. cloud-model at https://api.example/v1: 12 file(s)"),
"the fallback's line must name model, endpoint and count:\n{text}"
);
assert!(
text.contains("1. local-model at http://localhost:1234/v1: 4 file(s)"),
"the head's line gives the fallback's count its context:\n{text}"
);
}
#[test]
fn the_json_providers_array_is_present_on_a_single_provider_run() {
let outcome = outcome_serving(vec![provider_use(
0,
"local",
"http://localhost:1234/v1",
4,
)]);
let parsed = rendered_json(&outcome);
let providers = parsed["providers"].as_array().expect("providers array");
assert_eq!(providers.len(), 1);
assert_eq!(providers[0]["provider"], 1, "one-based, like `doctor`");
assert_eq!(providers[0]["model"], "local");
assert_eq!(providers[0]["endpoint"], "http://localhost:1234/v1");
assert_eq!(providers[0]["files"], 4);
}
#[test]
fn the_json_providers_array_is_present_and_empty_when_nothing_was_served() {
let outcome = outcome();
let parsed = rendered_json(&outcome);
assert_eq!(
parsed["providers"]
.as_array()
.expect("providers array")
.len(),
0
);
}
fn chain_failure() -> FailureReason {
FailureReason::ChainFailed(vec![
ProviderFailure {
provider: 0,
model: "local-model".to_owned(),
reason: FailureReason::Transport {
status: None,
message: "connection refused".to_owned(),
},
skipped: true,
},
ProviderFailure {
provider: 1,
model: "cloud-model".to_owned(),
reason: FailureReason::Transport {
status: Some(401),
message: "unauthorized".to_owned(),
},
skipped: false,
},
])
}
#[test]
fn a_chain_failure_reports_every_provider_in_the_text_output() {
let outcome = outcome_failing(vec![("src/lib.rs", chain_failure())]);
let text = rendered(&outcome, OutputFormat::Text);
assert!(
text.contains("no LLM provider analyzed this file"),
"{text}"
);
assert!(text.contains("[1] local-model"), "{text}");
assert!(text.contains("connection refused"), "{text}");
assert!(
text.contains("already down earlier in this run"),
"a skipped provider must be distinguishable from one just contacted:\n{text}"
);
assert!(text.contains("[2] cloud-model"), "{text}");
assert!(text.contains("401"), "{text}");
}
#[test]
fn a_chain_failure_carries_structured_per_provider_detail_in_json() {
let outcome = outcome_failing(vec![("src/lib.rs", chain_failure())]);
let parsed = rendered_json(&outcome);
let entry = &parsed["unanalyzed"][0];
assert_eq!(entry["kind"], "chain_failed");
assert!(
entry.get("status").is_none(),
"a chain failure has one status per provider; flattening them would \
claim the last provider's code was the whole story"
);
let providers = entry["providers"].as_array().expect("providers array");
assert_eq!(providers.len(), 2);
assert_eq!(providers[0]["provider"], 1, "one-based");
assert_eq!(providers[0]["model"], "local-model");
assert_eq!(providers[0]["kind"], "transport");
assert_eq!(providers[0]["skipped"], true);
assert!(
providers[0].get("status").is_none(),
"a refused connection has no HTTP status"
);
assert_eq!(providers[1]["provider"], 2);
assert_eq!(providers[1]["status"], 401);
assert_eq!(providers[1]["skipped"], false);
}
#[test]
fn a_single_provider_failure_still_renders_as_a_plain_transport_failure() {
let outcome = outcome_failing(vec![(
"src/lib.rs",
FailureReason::Transport {
status: Some(500),
message: "internal".to_owned(),
},
)]);
let parsed = rendered_json(&outcome);
let entry = &parsed["unanalyzed"][0];
assert_eq!(entry["kind"], "transport");
assert_eq!(entry["status"], 500);
assert!(entry.get("providers").is_none());
}
#[tokio::test]
async fn provider_uses_names_the_providers_that_answered_and_no_others() {
use crate::llm::chain::ProviderChain;
use crate::test_support::{
cfg_for, fast_retry_chain, server_failing_with, server_returning, temp_cache,
};
let dead = server_failing_with(500).await;
let healthy = server_returning(&[r#"{"issues": [], "summary": "ok"}"#]).await;
let (cache, _dir) = temp_cache();
let chain: ProviderChain = fast_retry_chain(&[
cfg_for(&dead, "model-a", 1),
cfg_for(&healthy, "model-b", 1),
]);
chain
.complete_json("system", "content", &cache)
.await
.expect("the fallback answers");
let uses = super::super::provider_uses(&chain);
assert_eq!(
uses.len(),
1,
"only the provider that answered is reported, got {:?}",
uses.iter().map(|u| u.index).collect::<Vec<_>>()
);
assert_eq!(uses[0].index, 1, "the fallback answered, not the head");
assert_eq!(uses[0].model, "model-b");
assert_eq!(uses[0].files, 1);
}
#[test]
fn the_failure_block_is_not_preceded_by_a_blank_line_when_there_are_no_findings() {
let outcome = outcome_failing(vec![(
"src/lib.rs",
FailureReason::Transport {
status: None,
message: "refused".to_owned(),
},
)]);
let text = rendered(&outcome, OutputFormat::Text);
assert!(
text.starts_with("1 file(s) could not be analyzed:"),
"the report must open with the failure block, not a blank line: {text:?}"
);
}
#[test]
fn the_failure_block_is_preceded_by_a_blank_line_when_findings_were_printed() {
use crate::analysis::findings::{Finding, Severity};
let mut outcome = outcome_failing(vec![(
"src/lib.rs",
FailureReason::Transport {
status: None,
message: "refused".to_owned(),
},
)]);
outcome.tool_findings = vec![Finding {
kind: "style".to_owned(),
severity: Severity::Error,
file_path: "src/other.rs".to_owned(),
line: 1,
column: None,
message: "m".to_owned(),
suggestion: None,
asserts_compile_failure: false,
fingerprint: None,
}];
let text = rendered(&outcome, OutputFormat::Text);
assert!(
text.contains("\n\n1 file(s) could not be analyzed:"),
"the two blocks must be separated: {text:?}"
);
}