use std::collections::HashMap;
use serde_json::Value as JsonValue;
use rigg_core::resources::ResourceKind;
use super::RiggDesiredState;
#[derive(Debug, thiserror::Error)]
pub enum PlanError {
#[error("rigg api: {0}")]
Api(String),
#[error("serialise: {0}")]
Serialise(#[from] serde_json::Error),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceRef {
pub kind: ResourceKind,
pub name: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FieldChange {
pub path: String,
pub from: JsonValue,
pub to: JsonValue,
}
#[derive(Debug, Clone)]
pub enum ResourceChange {
Create(ResourceRef),
Update {
rref: ResourceRef,
changes: Vec<FieldChange>,
},
Match(ResourceRef),
}
impl ResourceChange {
pub fn resource(&self) -> &ResourceRef {
match self {
ResourceChange::Create(r)
| ResourceChange::Update { rref: r, .. }
| ResourceChange::Match(r) => r,
}
}
}
#[derive(Debug, Default)]
pub struct RiggDiff {
pub changes: Vec<ResourceChange>,
}
impl RiggDiff {
pub fn pending(&self) -> impl Iterator<Item = &ResourceChange> {
self.changes
.iter()
.filter(|c| !matches!(c, ResourceChange::Match(_)))
}
pub fn is_clean(&self) -> bool {
self.pending().next().is_none()
}
pub fn render(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
if self.changes.is_empty() {
writeln!(&mut out, " (no managed resources)").ok();
return out;
}
for change in &self.changes {
match change {
ResourceChange::Create(r) => {
writeln!(&mut out, " + {}/{} (create)", kind_label(r.kind), r.name).ok();
}
ResourceChange::Update { rref, changes } => {
writeln!(
&mut out,
" ~ {}/{} (update)",
kind_label(rref.kind),
rref.name
)
.ok();
for fc in changes {
writeln!(
&mut out,
" {}: {} → {}",
fc.path,
short_value(&fc.from),
short_value(&fc.to)
)
.ok();
}
}
ResourceChange::Match(r) => {
writeln!(
&mut out,
" = {}/{} (no change)",
kind_label(r.kind),
r.name
)
.ok();
}
}
}
out
}
}
fn kind_label(k: ResourceKind) -> &'static str {
match k {
ResourceKind::Index => "indexes",
ResourceKind::DataSource => "data_sources",
ResourceKind::Skillset => "skillsets",
ResourceKind::Indexer => "indexers",
ResourceKind::KnowledgeSource => "knowledge_sources",
ResourceKind::KnowledgeBase => "knowledge_bases",
ResourceKind::SynonymMap => "synonym_maps",
ResourceKind::Alias => "aliases",
ResourceKind::Agent => "agents",
}
}
fn short_value(v: &JsonValue) -> String {
match v {
JsonValue::Null => "null".to_string(),
JsonValue::Bool(b) => b.to_string(),
JsonValue::Number(n) => n.to_string(),
JsonValue::String(s) => format!("\"{s}\""),
JsonValue::Array(_) | JsonValue::Object(_) => {
let s = serde_json::to_string(v).unwrap_or_else(|_| "<?>".to_string());
if s.len() > 60 {
format!("{}...", &s[..57])
} else {
s
}
}
}
}
pub const MANAGED_KINDS: &[ResourceKind] = &[
ResourceKind::DataSource,
ResourceKind::Skillset,
ResourceKind::Index,
ResourceKind::Indexer,
ResourceKind::KnowledgeSource,
ResourceKind::KnowledgeBase,
];
#[trait_variant::make(Send)]
pub trait RiggApiAdapter: Sync {
async fn list_resources(&self, kind: ResourceKind) -> Result<Vec<JsonValue>, anyhow::Error>;
async fn upsert_resource(
&self,
kind: ResourceKind,
name: &str,
body: &JsonValue,
) -> Result<(), anyhow::Error>;
}
pub struct RiggClientAdapter {
client: rigg_client::AzureSearchClient,
}
impl RiggClientAdapter {
pub fn new(base_url: String, preview_api_version: String) -> Result<Self, anyhow::Error> {
let auth =
rigg_client::auth::get_auth_provider().map_err(|e| anyhow::anyhow!("auth: {e}"))?;
let client = rigg_client::AzureSearchClient::with_auth(base_url, preview_api_version, auth)
.map_err(|e| anyhow::anyhow!("client: {e}"))?;
Ok(Self { client })
}
}
impl RiggApiAdapter for RiggClientAdapter {
async fn list_resources(&self, kind: ResourceKind) -> Result<Vec<JsonValue>, anyhow::Error> {
self.client
.list(kind)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}
async fn upsert_resource(
&self,
kind: ResourceKind,
name: &str,
body: &JsonValue,
) -> Result<(), anyhow::Error> {
self.client
.create_or_update(kind, name, body)
.await
.map(|_| ())
.map_err(|e| anyhow::anyhow!("{e}"))
}
}
pub async fn plan<A: RiggApiAdapter>(
desired: &RiggDesiredState,
api: &A,
) -> Result<RiggDiff, PlanError> {
let live = fetch_live(api).await?;
let desired_map = serialise_desired(desired)?;
let mut diff = RiggDiff::default();
for kind in MANAGED_KINDS {
let mut entries: Vec<(&String, &JsonValue)> = desired_map
.iter()
.filter(|((k, _), _)| k == kind)
.map(|((_, n), v)| (n, v))
.collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
for (name, want) in entries {
let rref = ResourceRef {
kind: *kind,
name: name.clone(),
};
match live.get(&(*kind, name.clone())) {
None => diff.changes.push(ResourceChange::Create(rref)),
Some(have) => {
let changes = diff_values(want, have, "");
if changes.is_empty() {
diff.changes.push(ResourceChange::Match(rref));
} else {
diff.changes.push(ResourceChange::Update { rref, changes });
}
}
}
}
}
Ok(diff)
}
type ResourceMap = HashMap<(ResourceKind, String), JsonValue>;
fn serialise_desired(state: &RiggDesiredState) -> Result<ResourceMap, PlanError> {
let mut map = ResourceMap::new();
for r in &state.data_sources {
map.insert(
(ResourceKind::DataSource, r.name.clone()),
serde_json::to_value(r)?,
);
}
for r in &state.skillsets {
map.insert(
(ResourceKind::Skillset, r.name.clone()),
serde_json::to_value(r)?,
);
}
for r in &state.indexes {
map.insert(
(ResourceKind::Index, r.name.clone()),
serde_json::to_value(r)?,
);
}
for r in &state.indexers {
map.insert(
(ResourceKind::Indexer, r.name.clone()),
serde_json::to_value(r)?,
);
}
for r in &state.knowledge_sources {
map.insert(
(ResourceKind::KnowledgeSource, r.name.clone()),
serde_json::to_value(r)?,
);
}
for r in &state.knowledge_bases {
map.insert(
(ResourceKind::KnowledgeBase, r.name.clone()),
serde_json::to_value(r)?,
);
}
Ok(map)
}
async fn fetch_live<A: RiggApiAdapter>(api: &A) -> Result<ResourceMap, PlanError> {
let mut map = ResourceMap::new();
for kind in MANAGED_KINDS {
let items = api
.list_resources(*kind)
.await
.map_err(|e| PlanError::Api(e.to_string()))?;
for mut item in items {
let name = item
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if !name.is_empty() {
strip_server_managed_fields(&mut item);
map.insert((*kind, name), item);
}
}
}
Ok(map)
}
fn strip_server_managed_fields(v: &mut JsonValue) {
match v {
JsonValue::Object(map) => {
map.retain(|k, _| !is_server_managed_key(k));
for child in map.values_mut() {
strip_server_managed_fields(child);
}
}
JsonValue::Array(arr) => {
for child in arr {
strip_server_managed_fields(child);
}
}
_ => {}
}
}
fn is_server_managed_key(k: &str) -> bool {
if k.starts_with("@odata.") || k.starts_with("@search.") {
return true;
}
matches!(k, "etag" | "lastModified" | "createdAt" | "modifiedAt")
}
fn diff_values(want: &JsonValue, have: &JsonValue, path: &str) -> Vec<FieldChange> {
let mut changes = Vec::new();
diff_values_inner(want, have, path, &mut changes);
changes
}
fn diff_values_inner(
want: &JsonValue,
have: &JsonValue,
path: &str,
changes: &mut Vec<FieldChange>,
) {
match (want, have) {
(JsonValue::Object(w_map), JsonValue::Object(h_map)) => {
for (k, w_v) in w_map {
let child = if path.is_empty() {
k.clone()
} else {
format!("{path}.{k}")
};
match h_map.get(k) {
None => changes.push(FieldChange {
path: child,
from: JsonValue::Null,
to: w_v.clone(),
}),
Some(h_v) => diff_values_inner(w_v, h_v, &child, changes),
}
}
for (k, h_v) in h_map {
if !w_map.contains_key(k) {
let child = if path.is_empty() {
k.clone()
} else {
format!("{path}.{k}")
};
changes.push(FieldChange {
path: child,
from: h_v.clone(),
to: JsonValue::Null,
});
}
}
}
(JsonValue::Array(w_arr), JsonValue::Array(h_arr)) => {
let max_len = w_arr.len().max(h_arr.len());
for i in 0..max_len {
let child = if path.is_empty() {
i.to_string()
} else {
format!("{path}.{i}")
};
match (w_arr.get(i), h_arr.get(i)) {
(Some(w), Some(h)) => diff_values_inner(w, h, &child, changes),
(Some(w), None) => changes.push(FieldChange {
path: child,
from: JsonValue::Null,
to: w.clone(),
}),
(None, Some(h)) => changes.push(FieldChange {
path: child,
from: h.clone(),
to: JsonValue::Null,
}),
(None, None) => {}
}
}
}
_ => {
if want != have {
changes.push(FieldChange {
path: path.to_string(),
from: have.clone(),
to: want.clone(),
});
}
}
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[derive(Default)]
pub struct MockRiggApi {
live: HashMap<ResourceKind, Vec<JsonValue>>,
pub upserted: Arc<Mutex<Vec<(ResourceKind, String, JsonValue)>>>,
}
impl MockRiggApi {
pub fn with_live(mut self, kind: ResourceKind, items: Vec<JsonValue>) -> Self {
self.live.insert(kind, items);
self
}
}
impl RiggApiAdapter for MockRiggApi {
async fn list_resources(
&self,
kind: ResourceKind,
) -> Result<Vec<JsonValue>, anyhow::Error> {
Ok(self.live.get(&kind).cloned().unwrap_or_default())
}
async fn upsert_resource(
&self,
kind: ResourceKind,
name: &str,
body: &JsonValue,
) -> Result<(), anyhow::Error> {
self.upserted
.lock()
.unwrap()
.push((kind, name.to_string(), body.clone()));
Ok(())
}
}
fn desired_with_one_index(name: &str) -> RiggDesiredState {
let mut state = RiggDesiredState::default();
state.indexes.push(rigg_core::resources::Index {
name: name.to_string(),
fields: vec![],
scoring_profiles: None,
default_scoring_profile: None,
cors_options: None,
suggesters: None,
analyzers: None,
tokenizers: None,
token_filters: None,
char_filters: None,
similarity: None,
semantic: None,
vector_search: None,
extra: Default::default(),
});
state
}
#[tokio::test]
async fn plan_creates_when_live_is_empty() {
let state = desired_with_one_index("jira-issues");
let api = MockRiggApi::default();
let diff = plan(&state, &api).await.unwrap();
assert_eq!(diff.changes.len(), 1);
assert!(matches!(diff.changes[0], ResourceChange::Create(_)));
assert!(!diff.is_clean());
}
#[tokio::test]
async fn plan_matches_when_live_has_identical_resource() {
let state = desired_with_one_index("jira-issues");
let live = serde_json::to_value(&state.indexes[0]).unwrap();
let api = MockRiggApi::default().with_live(ResourceKind::Index, vec![live]);
let diff = plan(&state, &api).await.unwrap();
assert_eq!(diff.changes.len(), 1);
assert!(matches!(diff.changes[0], ResourceChange::Match(_)));
assert!(diff.is_clean());
}
#[tokio::test]
async fn plan_updates_when_live_has_diverging_resource() {
let state = desired_with_one_index("jira-issues");
let live = serde_json::json!({
"name": "jira-issues",
"fields": [],
"extraServerField": "drift",
});
let api = MockRiggApi::default().with_live(ResourceKind::Index, vec![live]);
let diff = plan(&state, &api).await.unwrap();
assert_eq!(diff.changes.len(), 1);
match &diff.changes[0] {
ResourceChange::Update { changes, .. } => {
assert!(
changes.iter().any(|c| c.path == "extraServerField"),
"expected change on extraServerField, got: {changes:?}"
);
}
other => panic!("expected Update, got {other:?}"),
}
}
#[tokio::test]
async fn plan_does_not_emit_deletes_for_extra_live_resources() {
let state = RiggDesiredState::default();
let live = serde_json::json!({"name": "user-managed", "fields": []});
let api = MockRiggApi::default().with_live(ResourceKind::Index, vec![live]);
let diff = plan(&state, &api).await.unwrap();
assert!(diff.changes.is_empty());
assert!(diff.is_clean());
}
#[test]
fn render_handles_create_update_match_and_empty() {
let mut diff = RiggDiff::default();
assert!(diff.render().contains("(no managed resources)"));
diff.changes.push(ResourceChange::Create(ResourceRef {
kind: ResourceKind::Index,
name: "a".into(),
}));
diff.changes.push(ResourceChange::Match(ResourceRef {
kind: ResourceKind::DataSource,
name: "b".into(),
}));
diff.changes.push(ResourceChange::Update {
rref: ResourceRef {
kind: ResourceKind::Skillset,
name: "c".into(),
},
changes: vec![FieldChange {
path: "fields.0.searchable".into(),
from: JsonValue::Bool(false),
to: JsonValue::Bool(true),
}],
});
let s = diff.render();
assert!(s.contains("+ indexes/a"), "{s}");
assert!(s.contains("= data_sources/b"), "{s}");
assert!(s.contains("~ skillsets/c"), "{s}");
assert!(s.contains("fields.0.searchable: false → true"), "{s}");
}
#[test]
fn pending_excludes_match_entries() {
let mut diff = RiggDiff::default();
diff.changes.push(ResourceChange::Match(ResourceRef {
kind: ResourceKind::Index,
name: "a".into(),
}));
diff.changes.push(ResourceChange::Create(ResourceRef {
kind: ResourceKind::Index,
name: "b".into(),
}));
let pending: Vec<_> = diff.pending().collect();
assert_eq!(pending.len(), 1);
assert!(matches!(pending[0], ResourceChange::Create(_)));
}
#[test]
fn diff_values_detects_leaf_changes() {
let want = serde_json::json!({"x": 1});
let have = serde_json::json!({"x": 2});
let changes = diff_values(&want, &have, "");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path, "x");
assert_eq!(changes[0].from, serde_json::json!(2));
assert_eq!(changes[0].to, serde_json::json!(1));
}
#[tokio::test]
async fn plan_ignores_server_managed_fields_in_live_state() {
let state = desired_with_one_index("jira-issues");
let live = serde_json::json!({
"@odata.context": "https://srv.search.windows.net/$metadata#indexes/$entity",
"@odata.etag": "\"abc123\"",
"etag": "abc123",
"name": "jira-issues",
"fields": [],
});
let api = MockRiggApi::default().with_live(ResourceKind::Index, vec![live]);
let diff = plan(&state, &api).await.unwrap();
assert_eq!(diff.changes.len(), 1);
assert!(
matches!(diff.changes[0], ResourceChange::Match(_)),
"expected Match after stripping server-managed fields, got: {:?}",
diff.changes[0]
);
assert!(diff.is_clean());
}
#[test]
fn strip_server_managed_fields_removes_odata_and_etag() {
let mut v = serde_json::json!({
"@odata.context": "ctx",
"@odata.etag": "et",
"@search.action": "merge",
"etag": "abc",
"name": "x",
"fields": [
{"name": "a", "@odata.type": "#Edm.String", "type": "Edm.String"},
],
});
strip_server_managed_fields(&mut v);
let obj = v.as_object().unwrap();
assert!(!obj.contains_key("@odata.context"));
assert!(!obj.contains_key("@odata.etag"));
assert!(!obj.contains_key("@search.action"));
assert!(!obj.contains_key("etag"));
assert_eq!(obj.get("name").unwrap(), "x");
let inner = &v["fields"][0];
let inner_obj = inner.as_object().unwrap();
assert!(!inner_obj.contains_key("@odata.type"));
assert_eq!(inner_obj.get("name").unwrap(), "a");
}
}