pub mod discover;
pub mod prereq;
pub mod prompts;
pub mod templates;
use crate::config::{Config, IngestConfig, RiggConfig, StateConfig};
use std::path::Path;
#[derive(Debug, Default)]
pub struct InitOptions {
pub non_interactive: bool,
pub from_template: Option<String>,
pub force: bool,
}
pub async fn run(output_path: &Path, options: InitOptions) -> anyhow::Result<()> {
if output_path.exists() && !options.force {
anyhow::bail!(
"{} already exists. Use --force to overwrite.",
output_path.display()
);
}
if options.non_interactive {
let cfg = templates::template_for(options.from_template.as_deref().unwrap_or("minimal"))?;
write_yaml(&cfg, output_path)?;
println!(
"Wrote {} (template: {})",
output_path.display(),
options.from_template.as_deref().unwrap_or("minimal")
);
return Ok(());
}
let config = run_interactive().await?;
write_yaml(&config, output_path)?;
println!("Wrote {}", output_path.display());
println!(
"Next steps: review the config, fill in any ${{}}-placeholders, then run `quelch validate`."
);
Ok(())
}
async fn run_interactive() -> anyhow::Result<Config> {
println!("Welcome to quelch init.");
println!("This wizard will create a quelch.yaml for your environment.");
println!();
println!(
"What Quelch deploys for you:\n\
\x20 - Container Apps for Q-MCP and (optionally) Q-Ingest, via Bicep.\n\
\n\
What you must create up front (Quelch only references these — it does not\n\
provision them):\n\
\x20 - Cosmos DB account\n\
\x20 - Azure AI Search service\n\
\x20 - AI model provider (Microsoft Foundry project or Azure OpenAI account)\n\
\x20 with one embedding deployment and one chat deployment\n\
\x20 - Container Apps environment\n\
\x20 - Application Insights component\n\
\x20 - Key Vault\n\
\n\
These can each live in any resource group in your subscription — the\n\
wizard will let you point at each one individually. See\n\
docs/getting-started.md for the full prerequisites list and `az`\n\
commands."
);
println!();
let mut azure = prompts::azure_section().await?;
let ai = prompts::ai_section(&azure).await?;
let sources = prompts::sources_section().await?;
let deployments = prompts::deployments_section(&sources).await?;
if deployments
.iter()
.any(|d| matches!(d.target, crate::config::DeploymentTarget::Azure))
{
prompts::azure_deploy_settings(&mut azure).await?;
}
let mcp = prompts::mcp_section(&deployments).await?;
let config = Config {
azure,
cosmos: crate::config::CosmosConfig::default(),
search: crate::config::SearchConfig::default(),
ai,
sources,
ingest: IngestConfig::default(),
deployments,
mcp,
rigg: RiggConfig::default(),
state: StateConfig::default(),
};
let report = prereq::check_all(&config).await;
report.print();
if report.has_missing() {
println!(
"\nThe config has been written, but some prerequisites are missing.\n\
Create them in Azure, then run `quelch validate` to re-check."
);
}
Ok(config)
}
fn write_yaml(config: &Config, path: &Path) -> anyhow::Result<()> {
let yaml = serde_yaml::to_string(config)?;
std::fs::write(path, yaml)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::validate;
use tempfile::NamedTempFile;
fn temp_yaml_path() -> std::path::PathBuf {
let f = NamedTempFile::new().unwrap();
let p = f.path().to_path_buf();
drop(f); p
}
#[tokio::test]
async fn non_interactive_writes_minimal_template() {
let path = temp_yaml_path();
run(
&path,
InitOptions {
non_interactive: true,
from_template: None,
force: false,
},
)
.await
.unwrap();
assert!(path.exists(), "quelch.yaml must be written");
let written = std::fs::read_to_string(&path).unwrap();
let cfg: Config = serde_yaml::from_str(&written).unwrap();
validate::run(&cfg).expect("written config must pass validation");
}
#[tokio::test]
async fn non_interactive_respects_from_template() {
let path = temp_yaml_path();
run(
&path,
InitOptions {
non_interactive: true,
from_template: Some("multi-source".to_string()),
force: false,
},
)
.await
.unwrap();
let written = std::fs::read_to_string(&path).unwrap();
let cfg: Config = serde_yaml::from_str(&written).unwrap();
assert_eq!(cfg.sources.len(), 2);
}
#[tokio::test]
async fn refuses_overwrite_without_force() {
let path = temp_yaml_path();
std::fs::write(&path, "# existing").unwrap();
let err = run(
&path,
InitOptions {
non_interactive: true,
from_template: None,
force: false,
},
)
.await
.unwrap_err();
assert!(
err.to_string().contains("already exists"),
"error must mention 'already exists': {err}"
);
}
#[tokio::test]
async fn force_overwrites_existing_file() {
let path = temp_yaml_path();
std::fs::write(&path, "# old content").unwrap();
run(
&path,
InitOptions {
non_interactive: true,
from_template: None,
force: true,
},
)
.await
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(
!content.contains("# old content"),
"file should have been overwritten"
);
let cfg: Config = serde_yaml::from_str(&content).unwrap();
validate::run(&cfg).expect("overwritten config must be valid");
}
#[tokio::test]
async fn unknown_template_returns_error() {
let path = temp_yaml_path();
let err = run(
&path,
InitOptions {
non_interactive: true,
from_template: Some("does-not-exist".to_string()),
force: false,
},
)
.await
.unwrap_err();
assert!(
err.to_string().contains("does-not-exist"),
"error must mention the unknown template name: {err}"
);
}
}