use anyhow::{bail, Context, Result};
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use tokio::process::Command;
use tracing::{debug, info};
use tatara_core::domain::job::JobSpec;
use tatara_core::domain::source::{FlakeMetadata, SourceError};
pub struct NixEvaluator;
impl NixEvaluator {
pub async fn eval_file(path: &Path) -> Result<JobSpec> {
let path_str = path.to_str().context("Invalid path encoding")?;
info!(path = %path_str, "Evaluating Nix job spec");
let output = Command::new("nix")
.args(["eval", "--json", "--impure", "-f", path_str])
.output()
.await
.context("Failed to run nix eval")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("nix eval failed: {}", stderr);
}
let json =
String::from_utf8(output.stdout).context("nix eval output is not valid UTF-8")?;
debug!(json = %json, "Nix eval result");
let spec: JobSpec =
serde_json::from_str(&json).context("Failed to parse nix eval output as JobSpec")?;
Ok(spec)
}
pub async fn eval_expr(expr: &str) -> Result<JobSpec> {
let output = Command::new("nix")
.args(["eval", "--json", "--impure", "--expr", expr])
.output()
.await
.context("Failed to run nix eval")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("nix eval failed: {}", stderr);
}
let json =
String::from_utf8(output.stdout).context("nix eval output is not valid UTF-8")?;
let spec: JobSpec =
serde_json::from_str(&json).context("Failed to parse nix eval output as JobSpec")?;
Ok(spec)
}
pub async fn flake_metadata(
flake_ref: &str,
timeout_secs: u64,
) -> Result<FlakeMetadata, SourceError> {
info!(flake_ref = %flake_ref, "Fetching flake metadata");
let output = match tokio::time::timeout(
Duration::from_secs(timeout_secs),
Command::new("nix")
.args(["flake", "metadata", "--json", "--refresh", flake_ref])
.output(),
)
.await
{
Ok(Ok(output)) => output,
Ok(Err(e)) => {
return Err(SourceError::MetadataFetchFailed {
flake_ref: flake_ref.to_string(),
reason: format!("failed to run nix flake metadata: {}", e),
});
}
Err(_) => {
return Err(SourceError::Timeout {
flake_ref: flake_ref.to_string(),
timeout_secs,
});
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SourceError::MetadataFetchFailed {
flake_ref: flake_ref.to_string(),
reason: stderr.to_string(),
});
}
let json =
String::from_utf8(output.stdout).map_err(|e| SourceError::MetadataFetchFailed {
flake_ref: flake_ref.to_string(),
reason: format!("output is not valid UTF-8: {}", e),
})?;
let raw: serde_json::Value =
serde_json::from_str(&json).map_err(|e| SourceError::MetadataFetchFailed {
flake_ref: flake_ref.to_string(),
reason: format!("failed to parse JSON: {}", e),
})?;
let rev = raw
.get("revision")
.or_else(|| raw.get("rev"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let last_modified = raw
.get("lastModified")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let url = raw
.get("url")
.or_else(|| raw.get("resolvedUrl"))
.and_then(|v| v.as_str())
.unwrap_or(flake_ref)
.to_string();
debug!(rev = ?rev, last_modified = last_modified, url = %url, "Flake metadata");
Ok(FlakeMetadata {
rev,
last_modified,
url,
})
}
pub async fn validate_source(flake_ref: &str, source_name: &str) -> Result<(), SourceError> {
let output = Command::new("nix")
.args(["flake", "show", "--json", flake_ref])
.output()
.await
.map_err(|e| SourceError::ValidationFailed {
name: source_name.to_string(),
errors: vec![format!("failed to run nix flake show: {}", e)],
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SourceError::ValidationFailed {
name: source_name.to_string(),
errors: vec![format!("nix flake show failed: {}", stderr)],
});
}
let json = String::from_utf8(output.stdout).map_err(|e| SourceError::ValidationFailed {
name: source_name.to_string(),
errors: vec![format!("output is not valid UTF-8: {}", e)],
})?;
let raw: serde_json::Value =
serde_json::from_str(&json).map_err(|e| SourceError::ValidationFailed {
name: source_name.to_string(),
errors: vec![format!("failed to parse JSON: {}", e)],
})?;
let mut errors = Vec::new();
if raw.get("tataraJobs").is_none() {
errors.push(
"Missing 'tataraJobs' output — source must export tataraJobs.<system>".to_string(),
);
}
if raw.get("tataraMeta").is_none() {
errors.push(
"Missing 'tataraMeta' output — source should export name and version".to_string(),
);
}
if errors.is_empty() {
Ok(())
} else {
Err(SourceError::ValidationFailed {
name: source_name.to_string(),
errors,
})
}
}
pub async fn eval_tatara_jobs(
flake_ref: &str,
) -> Result<HashMap<String, JobSpec>, SourceError> {
let expr = format!(
"(builtins.getFlake \"{}\").tataraJobs.${{builtins.currentSystem}} or {{}}",
flake_ref
);
info!(flake_ref = %flake_ref, "Evaluating tataraJobs");
let output = Command::new("nix")
.args(["eval", "--json", "--impure", "--expr", &expr])
.output()
.await
.map_err(|e| SourceError::EvalFailed {
flake_ref: flake_ref.to_string(),
reason: format!("failed to run nix eval: {}", e),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SourceError::EvalFailed {
flake_ref: flake_ref.to_string(),
reason: stderr.to_string(),
});
}
let json = String::from_utf8(output.stdout).map_err(|e| SourceError::EvalFailed {
flake_ref: flake_ref.to_string(),
reason: format!("output is not valid UTF-8: {}", e),
})?;
debug!(json = %json, "tataraJobs eval result");
let jobs: HashMap<String, JobSpec> =
serde_json::from_str(&json).map_err(|e| SourceError::EvalFailed {
flake_ref: flake_ref.to_string(),
reason: format!("failed to parse tataraJobs: {}", e),
})?;
info!(count = jobs.len(), "Evaluated tataraJobs");
Ok(jobs)
}
}