use crate::config::Config;
use crate::config::schema::AiProvider;
use super::discover;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
Found,
Missing,
Unknown,
}
#[derive(Debug, Clone)]
pub struct Check {
pub label: String,
pub status: Status,
pub remediation: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Report {
pub checks: Vec<Check>,
}
impl Report {
pub fn has_missing(&self) -> bool {
self.checks.iter().any(|c| c.status == Status::Missing)
}
pub fn print(&self) {
println!("\nPrerequisite check:");
for c in &self.checks {
let glyph = match c.status {
Status::Found => " [OK]",
Status::Missing => " [!!]",
Status::Unknown => " [ ?]",
};
println!("{glyph} {}", c.label);
if c.status == Status::Missing
&& let Some(r) = &c.remediation
{
for line in r.lines() {
println!(" {line}");
}
}
}
let missing = self
.checks
.iter()
.filter(|c| c.status == Status::Missing)
.count();
let unknown = self
.checks
.iter()
.filter(|c| c.status == Status::Unknown)
.count();
if missing > 0 || unknown > 0 {
println!(
"\n{missing} prerequisite(s) missing, {unknown} could not be checked. \
Create them in Azure, then re-run `quelch validate`."
);
} else {
println!("\nAll prerequisites present.");
}
}
}
pub async fn check_all(config: &Config) -> Report {
let mut checks = Vec::new();
let cosmos = &config.azure.cosmos;
match (
cosmos.subscription_id.as_deref(),
cosmos.resource_group.as_deref(),
) {
(Some(sub), Some(rg)) => {
checks.push(check_cosmos(sub, rg, cosmos.account.as_deref()).await);
if let Some(search) = &config.azure.search {
checks.push(check_search(sub, rg, &search.endpoint).await);
}
if let Some(ai) = &config.azure.ai {
checks.push(check_ai_provider(sub, rg, ai).await);
}
}
_ => {
checks.push(Check {
label: "Cosmos DB account (skipped — subscription_id / resource_group not set)"
.to_string(),
status: Status::Unknown,
remediation: Some(
"Fill in `azure.cosmos.subscription_id` and `azure.cosmos.resource_group` \
in quelch.yaml so `quelch validate` can verify the account exists."
.to_string(),
),
});
}
}
Report { checks }
}
async fn check_cosmos(sub: &str, rg: &str, expected_name: Option<&str>) -> Check {
let label = match expected_name {
Some(n) => format!("Cosmos DB account '{n}' in '{rg}'"),
None => format!("Cosmos DB account in '{rg}'"),
};
let remediation = Some(format!(
"az cosmosdb create -n <name> -g {rg} \
--kind GlobalDocumentDB --capabilities EnableServerless"
));
let Ok(list) = discover::list_cosmos_accounts(sub, rg).await else {
return Check {
label,
status: Status::Unknown,
remediation,
};
};
let found = match expected_name {
Some(n) => list.iter().any(|a| a.name == n),
None => !list.is_empty(),
};
Check {
label,
status: if found {
Status::Found
} else {
Status::Missing
},
remediation,
}
}
async fn check_search(sub: &str, rg: &str, endpoint: &str) -> Check {
let label = format!("Azure AI Search service at {endpoint} (RG '{rg}')");
let create_hint = format!(
"az search service create -n <name> -g {rg} --sku basic\n\
(then enable the semantic ranker in the portal)"
);
let Ok(list) = discover::list_search_services(sub, rg).await else {
return Check {
label,
status: Status::Unknown,
remediation: Some(create_hint),
};
};
let expected_name = endpoint
.strip_prefix("https://")
.and_then(|rest| rest.split('.').next())
.unwrap_or("");
let found = !expected_name.is_empty() && list.iter().any(|s| s.name == expected_name);
if found {
return Check {
label,
status: Status::Found,
remediation: None,
};
}
let remediation = if list.is_empty() {
Some(format!(
"No AI Search services found in resource group '{rg}'. Create one:\n{create_hint}"
))
} else {
let names: Vec<&str> = list.iter().map(|s| s.name.as_str()).collect();
Some(format!(
"Resource group '{rg}' contains {} AI Search service(s): {}.\n\
Update `azure.search.endpoint` in quelch.yaml to one of those, or create a new one:\n{}",
names.len(),
names.join(", "),
create_hint
))
};
Check {
label,
status: Status::Missing,
remediation,
}
}
async fn check_ai_provider(sub: &str, rg: &str, ai: &crate::config::schema::AiConfig) -> Check {
let endpoint = &ai.endpoint;
match ai.provider {
AiProvider::AzureOpenai => {
let label = format!("Azure OpenAI account at {endpoint}");
let remediation = Some(format!(
"Create one in the portal or via:\n\
az cognitiveservices account create -n <name> -g {rg} \\\n \
--kind OpenAI --sku S0 -l <region>\n\
Then deploy the embedding and chat models you need."
));
let Ok(list) = discover::list_openai_accounts(sub, rg).await else {
return Check {
label,
status: Status::Unknown,
remediation,
};
};
let found = list.iter().any(|a| a.endpoint == *endpoint);
Check {
label,
status: if found {
Status::Found
} else {
Status::Missing
},
remediation,
}
}
AiProvider::Foundry => {
let label = format!("Microsoft Foundry project at {endpoint}");
let remediation = Some(format!(
"Create a Foundry project in the portal (https://ai.azure.com)\n\
inside resource group '{rg}', then deploy the embedding and\n\
chat models you need."
));
let Ok(list) = discover::list_foundry_projects(sub, rg).await else {
return Check {
label,
status: Status::Unknown,
remediation,
};
};
let found = list.iter().any(|p| p.endpoint == *endpoint);
Check {
label,
status: if found {
Status::Found
} else {
Status::Missing
},
remediation,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_has_missing_returns_true_when_any_check_missing() {
let r = Report {
checks: vec![
Check {
label: "a".into(),
status: Status::Found,
remediation: None,
},
Check {
label: "b".into(),
status: Status::Missing,
remediation: None,
},
],
};
assert!(r.has_missing());
}
#[test]
fn report_has_missing_returns_false_when_all_found_or_unknown() {
let r = Report {
checks: vec![
Check {
label: "a".into(),
status: Status::Found,
remediation: None,
},
Check {
label: "b".into(),
status: Status::Unknown,
remediation: None,
},
],
};
assert!(!r.has_missing());
}
#[tokio::test]
async fn check_all_emits_unknown_when_subscription_missing() {
let yaml = r#"
azure:
cosmos:
endpoint: https://example.documents.azure.com
database: quelch
source_connections: []
instances: []
"#;
let cfg: Config = serde_yaml::from_str(yaml).unwrap();
let report = check_all(&cfg).await;
assert_eq!(report.checks.len(), 1);
assert_eq!(report.checks[0].status, Status::Unknown);
}
}