use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub azure: AzureConfig,
#[serde(default)]
pub cosmos: CosmosConfig,
#[serde(default)]
pub search: SearchConfig,
pub openai: OpenAiConfig,
#[serde(default)]
pub sources: Vec<SourceConfig>,
#[serde(default)]
pub ingest: IngestConfig,
#[serde(default)]
pub deployments: Vec<DeploymentConfig>,
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)]
pub rigg: RiggConfig,
#[serde(default)]
pub state: StateConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AzureConfig {
pub subscription_id: String,
pub resource_group: String,
pub region: String,
#[serde(default)]
pub naming: NamingConfig,
#[serde(default)]
pub skip_role_assignments: bool,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct NamingConfig {
pub prefix: Option<String>,
pub environment: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CosmosConfig {
pub account: Option<String>,
#[serde(default = "default_cosmos_database")]
pub database: String,
#[serde(default)]
pub containers: CosmosContainersDefaults,
#[serde(default = "default_meta_container")]
pub meta_container: String,
#[serde(default)]
pub throughput: CosmosThroughput,
}
impl Default for CosmosConfig {
fn default() -> Self {
Self {
account: None,
database: default_cosmos_database(),
containers: CosmosContainersDefaults::default(),
meta_container: default_meta_container(),
throughput: CosmosThroughput::default(),
}
}
}
fn default_cosmos_database() -> String {
"quelch".to_string()
}
fn default_meta_container() -> String {
"quelch-meta".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CosmosContainersDefaults {
#[serde(default = "default_jira_issues")]
pub jira_issues: String,
#[serde(default = "default_confluence_pages")]
pub confluence_pages: String,
#[serde(default = "default_jira_sprints")]
pub jira_sprints: String,
#[serde(default = "default_jira_fix_versions")]
pub jira_fix_versions: String,
#[serde(default = "default_jira_projects")]
pub jira_projects: String,
#[serde(default = "default_confluence_spaces")]
pub confluence_spaces: String,
}
impl Default for CosmosContainersDefaults {
fn default() -> Self {
Self {
jira_issues: default_jira_issues(),
confluence_pages: default_confluence_pages(),
jira_sprints: default_jira_sprints(),
jira_fix_versions: default_jira_fix_versions(),
jira_projects: default_jira_projects(),
confluence_spaces: default_confluence_spaces(),
}
}
}
fn default_jira_issues() -> String {
"jira-issues".to_string()
}
fn default_confluence_pages() -> String {
"confluence-pages".to_string()
}
fn default_jira_sprints() -> String {
"jira-sprints".to_string()
}
fn default_jira_fix_versions() -> String {
"jira-fix-versions".to_string()
}
fn default_jira_projects() -> String {
"jira-projects".to_string()
}
fn default_confluence_spaces() -> String {
"confluence-spaces".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CosmosThroughput {
#[serde(default = "default_throughput_mode")]
pub mode: String,
pub ru_per_second: Option<u32>,
}
impl Default for CosmosThroughput {
fn default() -> Self {
Self {
mode: default_throughput_mode(),
ru_per_second: None,
}
}
}
fn default_throughput_mode() -> String {
"serverless".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SearchConfig {
pub service: Option<String>,
#[serde(default = "default_search_sku")]
pub sku: String,
#[serde(default)]
pub indexer: IndexerConfig,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
service: None,
sku: default_search_sku(),
indexer: IndexerConfig::default(),
}
}
}
fn default_search_sku() -> String {
"basic".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IndexerConfig {
#[serde(default)]
pub schedule: IndexerSchedule,
#[serde(default = "default_hwm_field")]
pub high_water_mark_field: String,
}
impl Default for IndexerConfig {
fn default() -> Self {
Self {
schedule: IndexerSchedule::default(),
high_water_mark_field: default_hwm_field(),
}
}
}
fn default_hwm_field() -> String {
"updated".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IndexerSchedule {
#[serde(default = "default_indexer_interval")]
pub interval: String,
}
impl Default for IndexerSchedule {
fn default() -> Self {
Self {
interval: default_indexer_interval(),
}
}
}
fn default_indexer_interval() -> String {
"PT15M".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OpenAiConfig {
pub endpoint: String,
pub embedding_deployment: String,
pub embedding_dimensions: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum SourceConfig {
#[serde(rename = "jira")]
Jira(JiraSourceConfig),
#[serde(rename = "confluence")]
Confluence(ConfluenceSourceConfig),
}
impl SourceConfig {
pub fn name(&self) -> &str {
match self {
SourceConfig::Jira(j) => &j.name,
SourceConfig::Confluence(c) => &c.name,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct JiraSourceConfig {
pub name: String,
pub url: String,
pub auth: AuthConfig,
pub projects: Vec<String>,
pub container: Option<String>,
#[serde(default)]
pub companion_containers: CompanionContainersConfig,
#[serde(default)]
pub fields: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ConfluenceSourceConfig {
pub name: String,
pub url: String,
pub auth: AuthConfig,
pub spaces: Vec<String>,
pub container: Option<String>,
#[serde(default)]
pub companion_containers: CompanionContainersConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum AuthConfig {
Cloud { email: String, api_token: String },
DataCenter { pat: String },
}
impl AuthConfig {
pub fn authorization_header(&self) -> String {
use base64::Engine;
match self {
AuthConfig::Cloud { email, api_token } => {
let credentials = format!("{email}:{api_token}");
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
format!("Basic {encoded}")
}
AuthConfig::DataCenter { pat } => {
format!("Bearer {pat}")
}
}
}
pub fn is_cloud(&self) -> bool {
matches!(self, AuthConfig::Cloud { .. })
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct CompanionContainersConfig {
pub sprints: Option<String>,
pub fix_versions: Option<String>,
pub projects: Option<String>,
pub spaces: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IngestConfig {
#[serde(default = "default_poll_interval")]
pub poll_interval: String,
#[serde(default = "default_safety_lag_minutes")]
pub safety_lag_minutes: u32,
#[serde(default = "default_batch_size")]
pub batch_size: u32,
#[serde(default = "default_reconcile_every")]
pub reconcile_every: u32,
#[serde(default = "default_max_cycle_duration")]
pub max_cycle_duration: String,
#[serde(default = "default_max_concurrent_per_source")]
pub max_concurrent_per_source: u32,
#[serde(default = "default_max_retries")]
pub max_retries: u32,
}
impl Default for IngestConfig {
fn default() -> Self {
Self {
poll_interval: default_poll_interval(),
safety_lag_minutes: default_safety_lag_minutes(),
batch_size: default_batch_size(),
reconcile_every: default_reconcile_every(),
max_cycle_duration: default_max_cycle_duration(),
max_concurrent_per_source: default_max_concurrent_per_source(),
max_retries: default_max_retries(),
}
}
}
fn default_poll_interval() -> String {
"300s".to_string()
}
fn default_safety_lag_minutes() -> u32 {
2
}
fn default_batch_size() -> u32 {
100
}
fn default_reconcile_every() -> u32 {
12
}
fn default_max_cycle_duration() -> String {
"30m".to_string()
}
fn default_max_concurrent_per_source() -> u32 {
1
}
fn default_max_retries() -> u32 {
5
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeploymentConfig {
pub name: String,
pub role: DeploymentRole,
pub target: DeploymentTarget,
pub sources: Option<Vec<DeploymentSource>>,
pub expose: Option<Vec<String>>,
pub azure: Option<DeploymentAzureConfig>,
pub auth: Option<DeploymentAuthConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DeploymentRole {
Ingest,
Mcp,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DeploymentTarget {
Azure,
Onprem,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeploymentSource {
pub source: String,
pub projects: Option<Vec<String>>,
pub spaces: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeploymentAzureConfig {
pub container_app: ContainerAppSpec,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ContainerAppSpec {
pub cpu: Option<f64>,
pub memory: Option<String>,
pub min_replicas: Option<u32>,
pub max_replicas: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeploymentAuthConfig {
pub mode: McpAuthMode,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum McpAuthMode {
ApiKey,
Entra,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpConfig {
#[serde(default)]
pub data_sources: HashMap<String, McpDataSourceSpec>,
pub search: Option<McpSearchConfig>,
#[serde(default = "default_mcp_default_top")]
pub default_top: u32,
#[serde(default = "default_mcp_max_top")]
pub max_top: u32,
#[serde(default = "default_mcp_query_timeout")]
pub query_timeout: String,
#[serde(default = "default_mcp_search_timeout")]
pub search_timeout: String,
}
impl Default for McpConfig {
fn default() -> Self {
Self {
data_sources: HashMap::new(),
search: None,
default_top: default_mcp_default_top(),
max_top: default_mcp_max_top(),
query_timeout: default_mcp_query_timeout(),
search_timeout: default_mcp_search_timeout(),
}
}
}
fn default_mcp_default_top() -> u32 {
25
}
fn default_mcp_max_top() -> u32 {
100
}
fn default_mcp_query_timeout() -> String {
"30s".to_string()
}
fn default_mcp_search_timeout() -> String {
"20s".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpDataSourceSpec {
pub kind: String,
pub backed_by: Vec<BackedBy>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct BackedBy {
pub container: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct McpSearchConfig {
#[serde(default)]
pub disable_agentic: bool,
pub knowledge_base: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RiggConfig {
#[serde(default = "default_rigg_dir")]
pub dir: String,
#[serde(default)]
pub ownership: RiggOwnership,
}
impl Default for RiggConfig {
fn default() -> Self {
Self {
dir: default_rigg_dir(),
ownership: RiggOwnership::default(),
}
}
}
fn default_rigg_dir() -> String {
"./rigg".to_string()
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RiggOwnership {
#[default]
Generated,
ManagedByUser,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct StateConfig {
#[serde(default)]
pub backend: StateBackend,
pub local_path: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum StateBackend {
#[default]
Cosmos,
LocalFile,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_v2_config() {
let yaml = r#"
azure:
subscription_id: "sub-123"
resource_group: "rg-test"
region: "swedencentral"
cosmos:
database: "quelch"
search:
sku: "basic"
openai:
endpoint: "https://test.openai.azure.com"
embedding_deployment: "text-embedding-3-large"
embedding_dimensions: 3072
sources:
- type: jira
name: jira-cloud
url: "https://example.atlassian.net"
auth:
email: "u@example.com"
api_token: "tok"
projects: ["DO"]
deployments:
- name: ingest
role: ingest
target: azure
sources:
- source: jira-cloud
- name: mcp
role: mcp
target: azure
expose: ["jira_issues"]
auth:
mode: "api_key"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.azure.region, "swedencentral");
assert_eq!(config.deployments.len(), 2);
}
#[test]
fn parses_full_v2_config() {
let yaml = r#"
azure:
subscription_id: "sub-456"
resource_group: "rg-prod"
region: "swedencentral"
naming:
prefix: "quelch"
environment: "prod"
cosmos:
account: "quelch-prod-cosmos"
database: "quelch"
containers:
jira_issues: "jira-issues"
confluence_pages: "confluence-pages"
jira_sprints: "jira-sprints"
jira_fix_versions: "jira-fix-versions"
jira_projects: "jira-projects"
confluence_spaces: "confluence-spaces"
meta_container: "quelch-meta"
throughput:
mode: "provisioned"
ru_per_second: 1000
search:
service: "quelch-prod-search"
sku: "standard"
indexer:
schedule:
interval: "PT15M"
high_water_mark_field: "updated"
openai:
endpoint: "https://prod.openai.azure.com"
embedding_deployment: "text-embedding-3-large"
embedding_dimensions: 3072
sources:
- type: jira
name: jira-cloud
url: "https://example.atlassian.net"
auth:
email: "user@example.com"
api_token: "tok"
projects: ["DO", "PROD"]
container: "jira-issues-cloud"
companion_containers:
sprints: "jira-sprints-cloud"
fix_versions: "jira-fix-versions-cloud"
projects: "jira-projects-cloud"
fields:
story_points: "customfield_10016"
- type: confluence
name: confluence-cloud
url: "https://example.atlassian.net/wiki"
auth:
email: "user@example.com"
api_token: "tok"
spaces: ["ENG"]
container: "confluence-pages-cloud"
companion_containers:
spaces: "confluence-spaces-cloud"
- type: jira
name: jira-dc
url: "https://jira.internal.example"
auth:
pat: "my-pat"
projects: ["INT"]
ingest:
poll_interval: "300s"
safety_lag_minutes: 2
batch_size: 100
reconcile_every: 12
max_cycle_duration: "30m"
max_concurrent_per_source: 1
max_retries: 5
deployments:
- name: ingest-azure
role: ingest
target: azure
azure:
container_app:
cpu: 0.5
memory: "1.0Gi"
min_replicas: 1
max_replicas: 1
sources:
- source: jira-cloud
- source: confluence-cloud
- name: ingest-onprem
role: ingest
target: onprem
sources:
- source: jira-dc
projects: ["INT"]
- name: mcp-azure
role: mcp
target: azure
azure:
container_app:
cpu: 1.0
memory: "2.0Gi"
min_replicas: 0
max_replicas: 5
expose:
- jira_issues
- confluence_pages
auth:
mode: "api_key"
mcp:
data_sources:
jira_issues:
kind: jira_issue
backed_by:
- container: jira-issues-cloud
search:
disable_agentic: false
knowledge_base: "quelch-prod-kb"
default_top: 25
max_top: 100
query_timeout: "30s"
search_timeout: "20s"
rigg:
dir: "./rigg"
ownership: "generated"
state:
backend: "cosmos"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.sources.len(), 3);
assert_eq!(config.deployments.len(), 3);
assert_eq!(config.cosmos.throughput.ru_per_second, Some(1000));
if let SourceConfig::Jira(j) = &config.sources[0] {
assert!(matches!(j.auth, AuthConfig::Cloud { .. }));
assert_eq!(
j.fields.get("story_points").map(String::as_str),
Some("customfield_10016")
);
} else {
panic!("expected Jira source");
}
if let SourceConfig::Jira(j) = &config.sources[2] {
assert!(matches!(j.auth, AuthConfig::DataCenter { .. }));
} else {
panic!("expected Jira source");
}
assert!(config.mcp.data_sources.contains_key("jira_issues"));
}
}