quelch 0.10.1

Ingest data from Jira, Confluence, and more directly into Azure AI Search
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
/// V2 configuration schema for Quelch.
///
/// Every field corresponds to a section or sub-field documented in
/// `docs/configuration.md`. The structs are plain data containers;
/// validation logic lives in `validate.rs`.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

// ---------------------------------------------------------------------------
// Top-level config
// ---------------------------------------------------------------------------

/// Root configuration loaded from `quelch.yaml`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
    pub azure: AzureConfig,
    #[serde(default)]
    pub cosmos: CosmosConfig,
    #[serde(default)]
    pub search: SearchConfig,
    pub ai: AiConfig,
    #[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,
}

// ---------------------------------------------------------------------------
// Azure
// ---------------------------------------------------------------------------

/// Azure subscription, resource group, region, and resource naming config.
#[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,
    /// Names of the existing Azure resources Quelch references but does not
    /// provision (Container Apps environment, App Insights, Key Vault).
    /// Cosmos account and AI Search service have their own dedicated config
    /// blocks (`cosmos.account` / `search.service`).
    #[serde(default)]
    pub resources: AzureExistingResources,
}

/// References to pre-existing Azure resources that Quelch needs to bind to
/// but does not provision itself. Each field defaults to a name derived from
/// `naming.prefix` + `naming.environment` if left unset.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct AzureExistingResources {
    pub container_apps_env: Option<String>,
    pub application_insights: Option<String>,
    pub key_vault: Option<String>,
}

/// Resource naming prefixes used when Quelch auto-generates Azure resource names.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct NamingConfig {
    pub prefix: Option<String>,
    pub environment: Option<String>,
}

// ---------------------------------------------------------------------------
// Cosmos DB
// ---------------------------------------------------------------------------

/// Cosmos DB account, database, container layout, and throughput config.
#[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()
}

/// Default Cosmos container names for each source-type entity class.
#[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()
}

/// Cosmos throughput mode and provisioned RU/s.
#[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()
}

// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------

/// Azure AI Search service configuration (name, SKU, indexer schedule).
#[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()
}

/// Indexer-level configuration (schedule and high-water-mark field).
#[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()
}

/// ISO 8601 duration string controlling how often the indexer runs.
#[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()
}

// ---------------------------------------------------------------------------
// AI provider (embedding + chat models)
// ---------------------------------------------------------------------------

/// Reference to an existing AI model provider — either an Azure OpenAI account
/// or a Microsoft Foundry project — that hosts both the embedding deployment
/// (used by the AI Search vectorizer / skillset) and the chat-completion
/// deployment (used by the Knowledge Base for query planning and answer
/// synthesis).
///
/// The on-the-wire JSON shape Azure AI Search emits is identical for both
/// providers; the `provider` field exists so `quelch init` knows which `az`
/// command surface to query when discovering deployments.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AiConfig {
    pub provider: AiProvider,
    pub endpoint: String,
    pub embedding: AiEmbeddingConfig,
    pub chat: AiChatConfig,
}

/// Which Azure surface holds the model deployments referenced by [`AiConfig`].
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiProvider {
    /// Classic Azure OpenAI account (`Microsoft.CognitiveServices/accounts`
    /// with `kind=OpenAI`).
    AzureOpenai,
    /// Microsoft Foundry project (`Microsoft.MachineLearningServices/workspaces`
    /// with `kind=Project`).
    Foundry,
}

/// Embedding deployment that the AI Search vectorizer / skillset will call.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AiEmbeddingConfig {
    pub deployment: String,
    pub dimensions: u32,
}

/// Chat-completion deployment that the Knowledge Base uses for agentic
/// retrieval (query planning + optional answer synthesis).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AiChatConfig {
    pub deployment: String,
    pub model_name: String,
    #[serde(default)]
    pub retrieval_reasoning_effort: ReasoningEffort,
    #[serde(default)]
    pub output_mode: OutputMode,
}

/// `retrievalReasoningEffort.kind` for the Knowledge Base.
///
/// `Minimal` skips the LLM (vector + keyword + semantic only). `Low` is the
/// portal default. `Medium` enables follow-up subqueries.
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningEffort {
    Minimal,
    #[default]
    Low,
    Medium,
}

/// Knowledge Base `outputMode`.
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
pub enum OutputMode {
    /// LLM-formulated natural-language answer with citations. Portal default.
    #[serde(rename = "answerSynthesis")]
    #[default]
    AnswerSynthesis,
    /// Raw ranked search results, no LLM-side composition.
    #[serde(rename = "extractedData")]
    ExtractedData,
}

// ---------------------------------------------------------------------------
// Sources
// ---------------------------------------------------------------------------

/// A source instance — either Jira or Confluence. Uses `type:` tag to
/// discriminate, following v1 convention.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum SourceConfig {
    #[serde(rename = "jira")]
    Jira(JiraSourceConfig),
    #[serde(rename = "confluence")]
    Confluence(ConfluenceSourceConfig),
}

impl SourceConfig {
    /// Returns the unique name of this source instance.
    pub fn name(&self) -> &str {
        match self {
            SourceConfig::Jira(j) => &j.name,
            SourceConfig::Confluence(c) => &c.name,
        }
    }
}

/// Jira source instance configuration.
#[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>,
}

/// Confluence source instance configuration.
#[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,
}

/// Auth for either Cloud (email + api_token) or Data Center (pat).
///
/// Untagged — serde tries Cloud first (requires both `email` and `api_token`),
/// then DataCenter (requires `pat`).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum AuthConfig {
    /// Atlassian Cloud: Basic auth with email and API token.
    Cloud { email: String, api_token: String },
    /// On-premises Data Center: PAT Bearer auth.
    DataCenter { pat: String },
}

impl AuthConfig {
    /// Build the Authorization header value for this auth config.
    ///
    /// Used by source connectors (`sources/jira.rs`, `sources/confluence.rs`).
    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}")
            }
        }
    }

    /// Returns `true` if this is a Cloud (email + api_token) auth config.
    pub fn is_cloud(&self) -> bool {
        matches!(self, AuthConfig::Cloud { .. })
    }
}

/// Per-source container name overrides for companion collections.
#[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>,
}

// ---------------------------------------------------------------------------
// Ingest
// ---------------------------------------------------------------------------

/// Global ingest worker behaviour knobs.
#[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
}

// ---------------------------------------------------------------------------
// Deployments
// ---------------------------------------------------------------------------

/// A named deployment — either an ingest worker or an MCP server instance.
#[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>,
}

/// Runtime role of a deployment.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DeploymentRole {
    Ingest,
    Mcp,
}

/// Where a deployment runs.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DeploymentTarget {
    Azure,
    Onprem,
}

/// A source slice within a deployment, optionally scoped to specific subsources.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeploymentSource {
    pub source: String,
    pub projects: Option<Vec<String>>,
    pub spaces: Option<Vec<String>>,
}

/// Azure-specific deployment configuration (Container App sizing).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeploymentAzureConfig {
    pub container_app: ContainerAppSpec,
}

/// Container App resource sizing and replica counts.
#[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>,
}

/// Auth mode for an MCP deployment.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeploymentAuthConfig {
    pub mode: McpAuthMode,
}

/// Authentication mode for the MCP server.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum McpAuthMode {
    ApiKey,
    Entra,
}

// ---------------------------------------------------------------------------
// MCP
// ---------------------------------------------------------------------------

/// Global MCP server configuration: logical data sources, search backend,
/// and server-level defaults.
#[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()
}

/// A logical MCP data source backed by one or more physical Cosmos containers.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpDataSourceSpec {
    pub kind: String,
    pub backed_by: Vec<BackedBy>,
}

/// A physical Cosmos container backing a logical data source.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct BackedBy {
    pub container: String,
}

/// MCP `search` tool backend options.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct McpSearchConfig {
    #[serde(default)]
    pub disable_agentic: bool,
    pub knowledge_base: Option<String>,
}

// ---------------------------------------------------------------------------
// Rigg
// ---------------------------------------------------------------------------

/// Configuration for the embedded rigg library that manages AI Search internals.
#[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()
}

/// Whether Quelch owns the rigg output directory or the user does.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RiggOwnership {
    #[default]
    Generated,
    ManagedByUser,
}

// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------

/// Cursor and worker-state storage backend config.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct StateConfig {
    #[serde(default)]
    pub backend: StateBackend,
    pub local_path: Option<String>,
}

/// Which backend stores ingest cursors.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum StateBackend {
    #[default]
    Cosmos,
    LocalFile,
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_minimal_config() {
        let yaml = r#"
azure:
  subscription_id: "sub-123"
  resource_group: "rg-test"
  region: "swedencentral"
cosmos:
  database: "quelch"
search:
  sku: "basic"
ai:
  provider: foundry
  endpoint: "https://test-foundry.cognitiveservices.azure.com"
  embedding:
    deployment: "text-embedding-3-large"
    dimensions: 3072
  chat:
    deployment: "gpt-5-mini"
    model_name: "gpt-5-mini"
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);
        assert!(matches!(config.ai.provider, AiProvider::Foundry));
        assert_eq!(config.ai.embedding.dimensions, 3072);
        assert_eq!(
            config.ai.chat.retrieval_reasoning_effort,
            ReasoningEffort::Low
        );
    }

    #[test]
    fn parses_full_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"

ai:
  provider: azure_openai
  endpoint: "https://prod.openai.azure.com"
  embedding:
    deployment: "text-embedding-3-large"
    dimensions: 3072
  chat:
    deployment: "gpt-5-mini"
    model_name: "gpt-5-mini"
    retrieval_reasoning_effort: medium
    output_mode: answerSynthesis

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));

        // Check Jira auth variants
        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");
        }

        // Check MCP data sources
        assert!(config.mcp.data_sources.contains_key("jira_issues"));
    }
}