use anyhow::{Result, anyhow};
use colored::Colorize;
use serde_json::Value;
use rigg_core::registry::{self, X_RIGG_API, X_RIGG_REF};
use rigg_core::resources::{ResourceKind, ResourceRef};
use rigg_core::store::{Store, assert_exclusive_ownership};
use rigg_core::workspace::{Project, Workspace};
use crate::cli::ValidateArgs;
use crate::commands::{CommandError, GlobalContext, load_workspace};
pub fn run(ctx: &GlobalContext, args: ValidateArgs) -> Result<()> {
let ws = load_workspace()?;
let projects = select_projects_lenient(&ws, args.project.as_deref())?;
let mut problems: Vec<String> = Vec::new();
let mut all_envs: Vec<String> = Vec::new();
for project in &ws.projects {
for e in Store::envs_of(project) {
if !all_envs.contains(&e) {
all_envs.push(e);
}
}
}
all_envs.sort();
for env in &all_envs {
if let Err(e) = assert_exclusive_ownership(&ws, env) {
problems.push(format!("[env {env}] {e}"));
}
let mut workspace_refs: Vec<ResourceRef> = Vec::new();
for project in &ws.projects {
if let Ok(list) = Store::new(project, env).list() {
workspace_refs.extend(list.into_iter().map(|(r, _)| r));
}
}
for project in &projects {
if !Store::envs_of(project).contains(env) {
continue; }
let proj_problems = validate_project(&ws, project, env, &workspace_refs, args.strict);
problems.extend(
proj_problems
.into_iter()
.map(|p| format!("[env {env}] {p}")),
);
}
}
if ctx.json() {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"valid": problems.is_empty(),
"problems": problems,
}))?
);
} else if problems.is_empty() {
println!("{} all checks passed", "✓".green().bold());
} else {
for p in &problems {
println!("{} {p}", "✗".red().bold());
}
}
if problems.is_empty() {
Ok(())
} else {
Err(anyhow!(CommandError::Validation(format!(
"{} validation problem(s) found",
problems.len()
))))
}
}
fn select_projects_lenient<'w>(
ws: &'w Workspace,
project: Option<&str>,
) -> Result<Vec<&'w Project>> {
match project {
Some(name) => Ok(vec![ws.project(name)?]),
None => Ok(ws.projects.iter().collect()),
}
}
fn validate_project(
ws: &Workspace,
project: &Project,
env: &str,
workspace_refs: &[ResourceRef],
strict: bool,
) -> Vec<String> {
let mut problems: Vec<String> = Vec::new();
let store = Store::new(project, env);
let list = match store.list() {
Ok(list) => list,
Err(e) => {
problems.push(format!("[{}] {e}", project.name));
return problems;
}
};
for (r, path) in &list {
let display = path
.strip_prefix(&ws.root)
.unwrap_or(path)
.display()
.to_string();
let value = match store.read(r) {
Ok(v) => v,
Err(e) => {
problems.push(format!("[{display}] {e}"));
continue;
}
};
if value.get("name").and_then(Value::as_str).is_none() {
problems.push(format!("[{display}] missing \"name\" field"));
}
check_secrets(r.kind, &value, &display, &mut problems);
for (kind, name) in registry::extract_references(r.kind, &value) {
if name.starts_with('<') {
problems.push(format!(
"[{display}] placeholder reference '{name}' — replace the scaffold placeholder"
));
continue;
}
let target = ResourceRef::new(kind, name.clone());
if !workspace_refs.contains(&target) {
if strict {
problems.push(format!(
"[{display}] references {target} which does not exist in this workspace"
));
} else {
eprintln!(
"{} [{display}] references {target} — not in this workspace (must already exist in Azure)",
"warning:".yellow()
);
}
}
}
check_api_links(ws, &value, &display, &mut problems);
if r.kind == ResourceKind::DataSource {
if let Some(ds_type) = value.get("type").and_then(Value::as_str) {
match rigg_core::scaffold::check_datasource_type(ds_type) {
Ok(Some(warning)) => eprintln!("{} [{display}] {warning}", "warning:".yellow()),
Ok(None) => {}
Err(e) => problems.push(format!("[{display}] {e}")),
}
warn_missing_deletion_tracking(ds_type, &value, &display);
} else {
problems.push(format!("[{display}] data source missing \"type\""));
}
warn_missing_credentials(&value, &display);
}
}
problems
}
fn warn_missing_credentials(value: &Value, display: &str) {
let conn = value
.pointer("/credentials/connectionString")
.and_then(Value::as_str)
.unwrap_or("");
if conn.trim().is_empty() {
eprintln!(
"{} [{display}] no credentials.connectionString — the indexer cannot reach the source; \
use identity-based access (ResourceId=/subscriptions/.../storageAccounts/<name>;)",
"warning:".yellow()
);
}
}
fn warn_missing_deletion_tracking(ds_type: &str, value: &Value, display: &str) {
let has_deletion_policy = value
.get("dataDeletionDetectionPolicy")
.is_some_and(|p| !p.is_null());
let integrated_sql = value
.get("dataChangeDetectionPolicy")
.and_then(|p| p.get("@odata.type"))
.and_then(Value::as_str)
.is_some_and(|t| t.contains("SqlIntegratedChangeTracking"));
if !has_deletion_policy && !integrated_sql {
let hint = match ds_type {
"azureblob" | "adlsgen2" | "azurefile" | "azurefiles" => {
"add NativeBlobSoftDeleteDeletionDetectionPolicy (and enable blob soft delete on the storage account)"
}
"cosmosdb" => "add SoftDeleteColumnDeletionDetectionPolicy on a soft-delete column",
"azuresql" => {
"use SqlIntegratedChangeTrackingPolicy (covers deletes) or a soft-delete column policy"
}
_ => "add a dataDeletionDetectionPolicy suited to the source",
};
eprintln!(
"{} [{display}] no deletion tracking — documents removed from the source will remain in the index; {hint}",
"warning:".yellow()
);
}
}
fn check_secrets(kind: ResourceKind, value: &Value, display: &str, problems: &mut Vec<String>) {
for spec in registry::meta(kind).secret_fields {
registry::collect_path(value, spec, &mut |v| {
if let Some(s) = v.as_str() {
if !s.is_empty() && !s.starts_with("ResourceId=") && !s.starts_with('<') {
problems.push(format!(
"[{display}] field '{spec}' contains a credential — rigg never stores secrets locally. \
Use a managed identity (connection string 'ResourceId=/subscriptions/...') and grant the \
identity RBAC access instead; secrets belong in Azure Key Vault, never in files"
));
}
}
});
}
let text = value.to_string();
if text.contains("AccountKey=") {
problems.push(format!(
"[{display}] contains an 'AccountKey=' connection string — replace it with an identity-based \
'ResourceId=...' connection and delete/rotate the leaked key"
));
}
}
fn check_api_links(ws: &Workspace, value: &Value, display: &str, problems: &mut Vec<String>) {
if let Some(skills) = value.get("skills").and_then(Value::as_array) {
for skill in skills {
let Some(api) = skill.get(X_RIGG_API).and_then(Value::as_str) else {
continue;
};
let path = ws.apis_dir().join(format!("{api}.json"));
if !path.is_file() {
problems.push(format!(
"[{display}] x-rigg-api '{api}' has no spec at apis/{api}.json (create with `rigg new api {api}`)"
));
continue;
}
let spec = match rigg_core::openapi::load(&path) {
Ok(spec) => spec,
Err(e) => {
problems.push(format!("[{display}] apis/{api}.json: {e}"));
continue;
}
};
check_skill_contract(skill, api, &spec, display, problems);
}
}
collect_key(value, X_RIGG_API, &mut |v| {
if let Some(api) = v.as_str() {
let path = ws.apis_dir().join(format!("{api}.json"));
if !path.is_file() && value.get("skills").is_none() {
problems.push(format!(
"[{display}] x-rigg-api '{api}' has no spec at apis/{api}.json (create with `rigg new api {api}`)"
));
}
}
});
collect_key(value, X_RIGG_REF, &mut |v| {
if let Some(s) = v.as_str() {
let valid = s
.split_once('/')
.and_then(|(dir, _)| ResourceKind::from_directory_name(dir))
.is_some();
if !valid {
problems.push(format!(
"[{display}] x-rigg-ref '{s}' is not of the form <kind-dir>/<name>"
));
}
}
});
}
fn check_skill_contract(
skill: &Value,
api: &str,
spec: &rigg_core::openapi::ApiSpec,
display: &str,
problems: &mut Vec<String>,
) {
if let Some(uri) = skill.get("uri").and_then(Value::as_str) {
if let Ok(parsed) = reqwest::Url::parse(uri) {
let uri_path = parsed.path();
if !uri_path.is_empty()
&& uri_path != "/"
&& !spec.paths.iter().any(|p| uri_path.ends_with(p.as_str()))
{
problems.push(format!(
"[{display}] WebApiSkill uri path '{uri_path}' does not match any path in apis/{api}.json ({})",
spec.paths.join(", ")
));
}
}
}
if !spec.open_props {
for (field, props) in [
("inputs", &spec.request_data_props),
("outputs", &spec.response_data_props),
] {
if let Some(entries) = skill.get(field).and_then(Value::as_array) {
for entry in entries {
let Some(name) = entry.get("name").and_then(Value::as_str) else {
continue;
};
if !props.contains(&name.to_string()) {
problems.push(format!(
"[{display}] skill {field} '{name}' is not in apis/{api}.json's data schema ({})",
props.join(", ")
));
}
}
}
}
}
}
fn collect_key(value: &Value, key: &str, f: &mut dyn FnMut(&Value)) {
match value {
Value::Object(map) => {
for (k, v) in map {
if k == key {
f(v);
} else {
collect_key(v, key, f);
}
}
}
Value::Array(arr) => {
for item in arr {
collect_key(item, key, f);
}
}
_ => {}
}
}