use std::io::Write;
use std::path::Path;
use anyhow::{Result, anyhow};
use clap::ValueEnum;
use crate::config::schema::{Config, InstanceKind};
use crate::config::slice::slice_for_instance;
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum InstanceKindArg {
Ingest,
Mcp,
}
impl From<InstanceKindArg> for InstanceKind {
fn from(arg: InstanceKindArg) -> Self {
match arg {
InstanceKindArg::Ingest => InstanceKind::Ingest,
InstanceKindArg::Mcp => InstanceKind::Mcp,
}
}
}
pub fn list(cfg: &Config, out: &mut dyn Write) -> Result<()> {
if cfg.instances.is_empty() {
writeln!(out, "(no instances declared)")?;
return Ok(());
}
let name_w = cfg
.instances
.iter()
.map(|i| i.name.len())
.max()
.unwrap_or(0);
for inst in &cfg.instances {
let kind = kind_label(inst.kind());
writeln!(out, " {:<name_w$} {kind}", inst.name)?;
}
Ok(())
}
fn kind_label(k: InstanceKind) -> &'static str {
match k {
InstanceKind::Ingest => "ingest",
InstanceKind::Mcp => "mcp",
}
}
pub fn config(
cfg: &Config,
instance_name: &str,
declared_kind: InstanceKindArg,
output: Option<&Path>,
out: &mut dyn Write,
) -> Result<()> {
let slice = slice_for_instance(cfg, instance_name)?;
let actual_kind = slice
.instances
.first()
.ok_or_else(|| anyhow!("internal: sliced config has no instance"))?
.kind();
let expected: InstanceKind = declared_kind.into();
if actual_kind != expected {
return Err(anyhow!(
"instance '{}' has kind '{}' in the config but --kind was '{}'",
instance_name,
kind_label(actual_kind),
kind_label(expected)
));
}
let yaml = serde_yaml::to_string(&slice)?;
match output {
Some(p) => {
std::fs::write(p, yaml).map_err(|e| anyhow!("writing slice to {}: {e}", p.display()))?
}
None => write!(out, "{yaml}")?,
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> Config {
serde_yaml::from_str(include_str!("../config/slice_test_fixture.yaml"))
.expect("fixture parses")
}
#[test]
fn config_kind_mismatch_errors() {
let cfg = fixture();
let mut sink = Vec::new();
let err = config(
&cfg,
"ingest-internal",
InstanceKindArg::Mcp,
None,
&mut sink,
)
.expect_err("kind mismatch must error");
let msg = err.to_string();
assert!(
msg.contains("'ingest'"),
"error should mention actual kind 'ingest' (lowercase), got: {msg}"
);
assert!(
msg.contains("'mcp'"),
"error should mention requested kind 'mcp' (lowercase), got: {msg}"
);
}
#[test]
fn config_ingest_emits_slimmed_yaml() {
let cfg = fixture();
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let mut sink = Vec::new();
config(
&cfg,
"ingest-internal",
InstanceKindArg::Ingest,
Some(tmp.path()),
&mut sink,
)
.expect("config write");
let bytes = std::fs::read(tmp.path()).expect("read tempfile");
let s = String::from_utf8(bytes).expect("utf8");
assert!(
!s.contains("subscription_id"),
"control-plane subscription_id must be stripped: {s}"
);
assert!(
!s.contains("resource_group"),
"control-plane resource_group must be stripped: {s}"
);
assert!(
!s.contains("account:"),
"control-plane account must be stripped: {s}"
);
assert!(
!s.contains("\nai:") && !s.starts_with("ai:"),
"ai block must be stripped: {s}"
);
assert!(
!s.contains("\nsearch:") && !s.contains(" search:"),
"search must be stripped for ingest: {s}"
);
assert!(
s.contains("source_connections:"),
"source_connections must be present for ingest: {s}"
);
}
#[test]
fn config_mcp_emits_slimmed_yaml_with_search_kept() {
let cfg = fixture();
let mut sink = Vec::new();
config(&cfg, "mcp-prod", InstanceKindArg::Mcp, None, &mut sink).expect("config write");
let s = String::from_utf8(sink).expect("utf8");
assert!(
!s.contains("subscription_id"),
"control-plane stripped: {s}"
);
assert!(
!s.contains("\nai:") && !s.starts_with("ai:"),
"ai must be stripped: {s}"
);
assert!(s.contains("search:"), "search must be kept for MCP: {s}");
}
#[test]
fn config_unknown_instance_errors() {
let cfg = fixture();
let mut sink = Vec::new();
let err = config(&cfg, "ghost", InstanceKindArg::Ingest, None, &mut sink)
.expect_err("unknown instance must error");
assert!(err.to_string().contains("ghost"));
}
#[test]
fn list_prints_each_instance_with_kind() {
let cfg = fixture();
let mut sink = Vec::new();
list(&cfg, &mut sink).expect("list");
let s = String::from_utf8(sink).expect("utf8");
let lines: Vec<&str> = s.lines().collect();
assert_eq!(lines.len(), 2, "fixture has two instances; got: {s:?}");
assert_eq!(lines[0], " ingest-internal ingest");
assert_eq!(lines[1], " mcp-prod mcp");
}
#[test]
fn list_empty_config_prints_placeholder() {
let mut cfg = fixture();
cfg.instances.clear();
let mut sink = Vec::new();
list(&cfg, &mut sink).expect("list");
let s = String::from_utf8(sink).expect("utf8");
assert_eq!(s.trim_end(), "(no instances declared)");
}
}