use std::ffi::OsString;
use std::fs;
use tokio::process::Command;
use crate::cli::{
ProviderCatalogCommand, ProvidersMatrixArgs, ProvidersRecommendArgs, ProvidersRefreshArgs,
};
mod artifacts;
mod build;
mod overlay_audit;
mod source_snapshot;
mod tool_probe_audit;
mod tool_probe_request;
pub(crate) use artifacts::{run_export, run_validate};
pub(crate) use build::run_generate;
pub(crate) use overlay_audit::run_overlay_audit;
pub(crate) use source_snapshot::{load_source_snapshot, ProviderSourceSnapshot};
pub(crate) use tool_probe_audit::run as run_audit;
pub(crate) use tool_probe_request::{
render as render_tool_probe_request, resolve_probe_wire_model,
};
pub(crate) async fn dispatch_catalog(command: ProviderCatalogCommand) {
let outcome = match &command {
ProviderCatalogCommand::Refresh(refresh) => run_refresh(refresh).await,
ProviderCatalogCommand::Validate(validate) => run_validate(validate),
ProviderCatalogCommand::Generate(generate) => run_generate(generate),
ProviderCatalogCommand::Export(export) => run_export(export),
ProviderCatalogCommand::OverlayAudit(audit) => run_overlay_audit(audit),
ProviderCatalogCommand::Matrix(matrix) => run_matrix(matrix),
ProviderCatalogCommand::Support(support) => crate::commands::provider_support::run(support),
ProviderCatalogCommand::Recommend(recommend) => run_recommend(recommend).await,
ProviderCatalogCommand::Show(show) => {
crate::cli::refresh_provider_catalog_if_requested(show).await;
let exit_code = crate::dispatch_provider_catalog(show.available_only).await;
crate::runtime::exit_on_error(exit_code);
Ok(())
}
};
if let Err(error) = outcome {
crate::command_error(&error);
}
}
pub(crate) async fn run_refresh(args: &ProvidersRefreshArgs) -> Result<(), String> {
if !args.script.exists() {
return Err(format!(
"provider refresh script not found: {}",
args.script.display()
));
}
let exe = std::env::current_exe()
.map_err(|error| format!("failed to resolve current executable: {error}"))?;
let mut command = Command::new(exe);
command.kill_on_drop(true);
command.args(refresh_run_args(args));
let mut child = command
.spawn()
.map_err(|error| format!("failed to run provider refresh workflow: {error}"))?;
let status = match refresh_timeout(args) {
None => child
.wait()
.await
.map_err(|error| format!("failed to run provider refresh workflow: {error}"))?,
Some(limit) => match tokio::time::timeout(limit, child.wait()).await {
Ok(status) => status
.map_err(|error| format!("failed to run provider refresh workflow: {error}"))?,
Err(_) => {
child.kill().await.map_err(|error| {
format!(
"provider refresh workflow exceeded {}s and could not be stopped: {error}",
limit.as_secs()
)
})?;
return Err(refresh_timeout_message(args, limit));
}
},
};
if status.success() {
Ok(())
} else {
Err(format!(
"provider refresh workflow exited with {}",
status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "signal".to_string())
))
}
}
pub(crate) const DEFAULT_REFRESH_TIMEOUT_SECS: u64 = 120;
pub(crate) fn refresh_timeout(args: &ProvidersRefreshArgs) -> Option<std::time::Duration> {
if args.timeout_secs == 0 {
return None;
}
Some(std::time::Duration::from_secs(args.timeout_secs))
}
pub(crate) fn refresh_timeout_message(
args: &ProvidersRefreshArgs,
limit: std::time::Duration,
) -> String {
let sources = if args.live {
"live provider and model endpoints"
} else {
"bundled offline fixtures"
};
format!(
"provider refresh workflow timed out after {}s waiting on {} via {}. \
This is a timeout, not an empty catalog: the refresh did not complete \
and its outputs must not be treated as authoritative. Raise \
--timeout-secs, pass \
--timeout-secs 0 to wait indefinitely, or drop --live to refresh from \
the committed fixtures without a network call.",
limit.as_secs(),
sources,
args.script.display(),
)
}
fn refresh_run_args(args: &ProvidersRefreshArgs) -> Vec<OsString> {
let mut command = vec![OsString::from("run")];
if args.live {
command.push(OsString::from("--allow-process-network"));
}
command.extend([args.script.as_os_str().to_owned(), OsString::from("--")]);
if args.live {
command.push(OsString::from("--live"));
}
if args.check || args.update {
command.push(OsString::from("--check"));
}
if args.update {
command.push(OsString::from("--update"));
}
command
}
pub(crate) fn run_matrix(args: &ProvidersMatrixArgs) -> Result<(), String> {
let snapshot = load_source_snapshot()?;
let rows = crate::commands::check::provider_matrix::filter_rows(
harn_vm::llm::capabilities::matrix_rows_for_base(&snapshot.capabilities),
args.filter.as_deref(),
);
let catalog = crate::commands::check::provider_matrix::catalog_with_empirical(
snapshot.catalog,
&args.empirical,
)?;
let generated = crate::commands::check::provider_matrix::generate_markdown(&rows, &catalog);
if args.check {
match fs::read_to_string(&args.output) {
Ok(existing) if existing == generated => {
if !args.stdout {
println!("provider capability matrix is up to date");
return Ok(());
}
}
Ok(_) | Err(_) => {
return Err(format!(
"provider capability matrix is stale or missing: {}",
args.output.display()
));
}
}
}
if args.stdout {
print!("{generated}");
return Ok(());
}
if let Some(parent) = args
.output
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(|error| {
format!(
"failed to create provider matrix directory {}: {error}",
parent.display()
)
})?;
}
fs::write(&args.output, generated)
.map_err(|error| format!("failed to write {}: {error}", args.output.display()))?;
println!("wrote {}", args.output.display());
Ok(())
}
pub(crate) async fn run_recommend(args: &ProvidersRecommendArgs) -> Result<(), String> {
let exit_code = run_recommend_dispatch(args).await?;
if exit_code != 0 {
std::process::exit(exit_code);
}
Ok(())
}
async fn run_recommend_dispatch(args: &ProvidersRecommendArgs) -> Result<i32, String> {
let report = load_filtered_recommend_report(args)?;
let payload_json = serde_json::to_string(&report)
.map_err(|error| format!("failed to serialise recommend payload: {error}"))?;
let payload_pretty = serde_json::to_string_pretty(&report)
.map_err(|error| format!("failed to render recommend payload: {error}"))?;
static DISPATCH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
let _guard = DISPATCH_LOCK.lock().await;
let _payload_guard =
crate::env_guard::ScopedEnvVar::set("HARN_PROVIDERS_RECOMMEND_PAYLOAD_JSON", &payload_json);
let _pretty_guard = crate::env_guard::ScopedEnvVar::set(
"HARN_PROVIDERS_RECOMMEND_PAYLOAD_PRETTY",
&payload_pretty,
);
let outcome =
crate::dispatch::run_embedded_script("providers/recommend", Vec::new(), args.json).await;
if !outcome.stderr.is_empty() {
use std::io::Write as _;
let _ = std::io::stderr().write_all(outcome.stderr.as_bytes());
}
if !outcome.stdout.is_empty() {
use std::io::Write as _;
let _ = std::io::stdout().write_all(outcome.stdout.as_bytes());
}
Ok(outcome.exit_code)
}
fn load_filtered_recommend_report(
args: &ProvidersRecommendArgs,
) -> Result<crate::commands::local_readiness::LocalReadinessReport, String> {
let report = if let Some(summary) = args.summary.as_deref() {
crate::commands::local_readiness::report_from_summary_path(summary)?
} else if let Some(input) = args.input.as_deref() {
crate::commands::local_readiness::load_report_or_summary(input)?
} else {
crate::commands::local_readiness::load_default_report()?
};
Ok(crate::commands::local_readiness::filter_report_by_provider(
report,
args.provider.as_deref(),
))
}
#[cfg(test)]
mod tests {
use super::{
refresh_run_args, refresh_timeout, refresh_timeout_message, DEFAULT_REFRESH_TIMEOUT_SECS,
};
use crate::cli::ProvidersRefreshArgs;
use std::path::PathBuf;
use std::time::Duration;
fn args(live: bool, timeout_secs: u64) -> ProvidersRefreshArgs {
ProvidersRefreshArgs {
live,
check: false,
update: false,
script: PathBuf::from("scripts/update_provider_catalog.harn"),
timeout_secs,
}
}
#[test]
fn the_refresh_is_bounded_by_default() {
assert_eq!(
refresh_timeout(&args(true, DEFAULT_REFRESH_TIMEOUT_SECS)),
Some(Duration::from_secs(DEFAULT_REFRESH_TIMEOUT_SECS)),
);
}
#[test]
fn zero_is_the_explicit_opt_out_and_not_an_instant_timeout() {
assert_eq!(refresh_timeout(&args(true, 0)), None);
}
#[test]
fn a_timeout_names_the_bound_and_the_source_class() {
let message = refresh_timeout_message(&args(true, 30), Duration::from_secs(30));
assert!(message.contains("timed out after 30s"), "{message}");
assert!(
message.contains("live provider and model endpoints"),
"a timeout must name what it was waiting on: {message}",
);
assert!(
message.contains("update_provider_catalog.harn"),
"{message}"
);
}
#[test]
fn a_timeout_is_never_reported_as_an_empty_catalog() {
let message = refresh_timeout_message(&args(true, 30), Duration::from_secs(30));
assert!(message.contains("not an empty catalog"), "{message}");
assert!(!message.to_lowercase().contains("no models"), "{message}");
assert!(
message.contains("outputs must not be treated as authoritative"),
"{message}"
);
}
#[test]
fn an_offline_refresh_says_so_rather_than_blaming_the_network() {
let message = refresh_timeout_message(&args(false, 5), Duration::from_secs(5));
assert!(message.contains("bundled offline fixtures"), "{message}");
assert!(
!message.contains("live provider and model endpoints"),
"{message}",
);
}
#[test]
fn offline_refresh_cannot_inherit_process_network_access() {
let command = refresh_run_args(&args(false, 5));
assert!(
!command.iter().any(|arg| arg == "--allow-process-network"),
"offline refresh must be structurally disconnected from process network: {command:?}",
);
assert!(
!command.iter().any(|arg| arg == "--live"),
"offline refresh must not select live sources: {command:?}",
);
}
#[test]
fn live_refresh_explicitly_enables_network_and_live_sources() {
let command = refresh_run_args(&args(true, 5));
assert!(command.iter().any(|arg| arg == "--allow-process-network"));
assert!(command.iter().any(|arg| arg == "--live"));
}
}