use std::process::Command;
#[derive(Debug, Clone)]
pub struct IndexerStatus {
pub name: String,
pub last_result: Option<String>,
pub last_run_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, thiserror::Error)]
pub enum IndexerError {
#[error("az command failed: {0}")]
AzFailed(String),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
pub fn run(search_service: &str, indexer_name: &str) -> Result<(), IndexerError> {
let status = Command::new("az")
.args([
"search",
"indexer",
"run",
"--service-name",
search_service,
"--name",
indexer_name,
])
.status()?;
if !status.success() {
return Err(IndexerError::AzFailed(format!(
"indexer run failed for '{indexer_name}'"
)));
}
Ok(())
}
pub fn reset(search_service: &str, indexer_name: &str) -> Result<(), IndexerError> {
let status = Command::new("az")
.args([
"search",
"indexer",
"reset",
"--service-name",
search_service,
"--name",
indexer_name,
])
.status()?;
if !status.success() {
return Err(IndexerError::AzFailed(format!(
"indexer reset failed for '{indexer_name}'"
)));
}
Ok(())
}
pub fn status(search_service: &str) -> Result<Vec<IndexerStatus>, IndexerError> {
let output = Command::new("az")
.args([
"search",
"indexer",
"list",
"--service-name",
search_service,
])
.output()?;
if !output.status.success() {
return Err(IndexerError::AzFailed(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
let items = match json.as_array() {
Some(arr) => arr.clone(),
None => {
json.get("value")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
};
let mut result = Vec::with_capacity(items.len());
for item in &items {
let name = item
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if name.is_empty() {
continue;
}
let last_result = item
.pointer("/lastResult/status")
.and_then(|v| v.as_str())
.map(String::from);
let last_run_at = item
.pointer("/lastResult/endTime")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<chrono::DateTime<chrono::Utc>>().ok());
result.push(IndexerStatus {
name,
last_result,
last_run_at,
});
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_parses_flat_array() {
let json = serde_json::json!([
{
"name": "jira-issues",
"lastResult": {
"status": "success",
"endTime": "2026-04-30T10:00:00Z"
}
},
{
"name": "confluence-pages",
"lastResult": {
"status": "transientFailure",
"endTime": "2026-04-30T09:00:00Z"
}
}
]);
let items = json.as_array().unwrap();
let parsed: Vec<IndexerStatus> = items
.iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let last_result = item
.pointer("/lastResult/status")
.and_then(|v| v.as_str())
.map(String::from);
let last_run_at = item
.pointer("/lastResult/endTime")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<chrono::DateTime<chrono::Utc>>().ok());
Some(IndexerStatus {
name,
last_result,
last_run_at,
})
})
.collect();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].name, "jira-issues");
assert_eq!(parsed[0].last_result.as_deref(), Some("success"));
assert!(parsed[0].last_run_at.is_some());
assert_eq!(parsed[1].name, "confluence-pages");
assert_eq!(parsed[1].last_result.as_deref(), Some("transientFailure"));
}
#[test]
fn status_parses_value_wrapped_response() {
let json = serde_json::json!({
"value": [
{ "name": "my-indexer", "lastResult": null }
]
});
let items = json.get("value").and_then(|v| v.as_array()).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].get("name").unwrap().as_str(), Some("my-indexer"));
}
#[test]
fn indexer_error_formats_correctly() {
let err = IndexerError::AzFailed("failed to run indexer 'x'".to_string());
assert!(err.to_string().contains("az command failed"));
assert!(err.to_string().contains("failed to run indexer 'x'"));
}
}