use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub use crate::types::{ArtifactName, EvalId};
mod identities;
pub use identities::{
ChildPrefix, EndpointId, ExecutionPinName, HubEpoch, LogicalPath, ModelId, ProfileVariantName,
ReferenceName, ToolName, ToolOperationName,
};
use serde_json::{Map, Value};
use thiserror::Error;
mod discovery;
mod location;
mod mutex;
pub use discovery::read_workspace_config;
pub(crate) use discovery::read_workspace_declarations;
use discovery::{logical_join, read_regular_text};
pub use mutex::MutexTag;
mod plain;
mod stored;
mod tools;
pub(crate) mod validation;
pub use stored::{Field, StoredPayload, StoredProfile};
pub use validation::MAX_TIMEOUT_MS;
pub use tools::{
AgentTool, Builtin, BuiltinTool, CommandTool, HumanBuiltinTool, HumanCommandTool, HumanTool,
HumanToolKind, SessionCommand, ToolProtocol, ToolSession,
};
use location::Location;
pub(crate) use validation::identifier;
use validation::{path, paths, positive_integer, present, script, tags, text, timeout};
pub const CONFIG_FORMAT_VERSION: u32 = 1;
pub const CONFIG_FILE: &str = "index.artf";
const MAX_DECLARED_ITEMS: usize = 64;
pub const IGNORE_FILE: &str = ".artfignore";
#[derive(Debug, Error)]
#[error("{}: {message}", crate::platform::path_text(.path))]
pub struct ConfigError {
pub path: PathBuf,
pub message: String,
}
impl ConfigError {
pub(crate) fn new(path: impl Into<PathBuf>, message: impl ToString) -> Self {
Self {
path: path.into(),
message: message.to_string(),
}
}
pub(crate) fn declaration(
path: impl Into<PathBuf>,
keys: &[&str],
message: impl ToString,
) -> Self {
let path = path.into();
let message = message.to_string();
let message = read_regular_text(&path).map_or(message.clone(), |source| {
location::error_at(&source, keys, &message)
});
Self { path, message }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
Anthropic,
Codex,
Openai,
}
const REMOVED_BACKENDS: [&str; 2] = ["chatgpt", "claude"];
impl<'de> Deserialize<'de> for Backend {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
let name = String::deserialize(deserializer)?;
match name.as_str() {
"openai" => Ok(Self::Openai),
"anthropic" => Ok(Self::Anthropic),
"codex" => Ok(Self::Codex),
removed if REMOVED_BACKENDS.contains(&removed) => Err(D::Error::custom(format!(
r#"backend "{removed}" was removed in 0.5.0; use "openai" or "anthropic" with an API key, or "codex" with a ChatGPT/Codex sign-in"#
))),
other => Err(D::Error::unknown_variant(
other,
&["openai", "anthropic", "codex"],
)),
}
}
}
impl std::fmt::Display for Backend {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Openai => "openai",
Self::Anthropic => "anthropic",
Self::Codex => "codex",
})
}
}
impl Backend {
pub fn as_str(self) -> &'static str {
match self {
Self::Anthropic => "anthropic",
Self::Codex => "codex",
Self::Openai => "openai",
}
}
pub fn validate_reasoning(self, reasoning: &str) -> Result<(), String> {
let supported = match self {
Self::Openai | Self::Codex => matches!(
reasoning,
"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
),
Self::Anthropic => matches!(reasoning, "low" | "medium" | "high" | "max"),
};
if supported {
Ok(())
} else {
Err(format!(
"Unsupported reasoning {reasoning:?} for {self:?}; effort is never remapped."
))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Reasoning {
None,
Minimal,
Low,
Medium,
High,
Xhigh,
Max,
}
impl Reasoning {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Xhigh => "xhigh",
Self::Max => "max",
}
}
}
impl std::fmt::Display for Reasoning {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
pub enum Profile {
Agent {
backend: Backend,
model: ModelId,
#[serde(default, deserialize_with = "present")]
reasoning: Option<Reasoning>,
#[serde(
rename(serialize = "timeoutMs", deserialize = "timeout_ms"),
default,
deserialize_with = "timeout",
serialize_with = "validation::milliseconds::serialize"
)]
timeout_ms: Option<std::time::Duration>,
#[serde(
rename(serialize = "maxToolCalls", deserialize = "max_tool_calls"),
default,
deserialize_with = "positive_integer"
)]
max_tool_calls: Option<u64>,
#[serde(
rename(serialize = "maxTokens", deserialize = "max_tokens"),
default,
deserialize_with = "positive_integer"
)]
max_tokens: Option<u64>,
},
Human {},
Runtime {
command: String,
args: Vec<crate::scope::Argument>,
#[serde(
rename(serialize = "timeoutMs", deserialize = "timeout_ms"),
default,
deserialize_with = "timeout",
serialize_with = "validation::milliseconds::serialize"
)]
timeout_ms: Option<std::time::Duration>,
},
}
impl Profile {
fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
match self {
Self::Agent {
backend,
model,
reasoning,
..
} => {
location
.child("model")
.check(text(model, "Agent profile model"))?;
if let Some(reasoning) = reasoning {
location
.child("reasoning")
.check(backend.validate_reasoning(reasoning.as_str()))?;
}
Ok(())
}
Self::Human {} => Ok(()),
Self::Runtime { command, args, .. } => location.check(script(command, args)),
}
}
pub fn kind(&self) -> ProfileKind {
match self {
Self::Agent { .. } => ProfileKind::Agent,
Self::Human {} => ProfileKind::Human,
Self::Runtime { .. } => ProfileKind::Runtime,
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, clap::ValueEnum,
)]
#[serde(rename_all = "lowercase")]
pub enum ProfileKind {
Runtime,
Agent,
Human,
}
impl ProfileKind {
pub fn name(self) -> &'static str {
match self {
Self::Runtime => "runtime",
Self::Agent => "agent",
Self::Human => "human",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalPayload {
pub instruction: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct LocalEvalId(String);
impl<'de> Deserialize<'de> for LocalEvalId {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
impl std::str::FromStr for LocalEvalId {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
identifier(value, "Eval id")?;
Ok(Self(value.to_owned()))
}
}
impl LocalEvalId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl PartialEq<&str> for LocalEvalId {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl std::ops::Deref for LocalEvalId {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for LocalEvalId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Gate {
Blocking,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EvalDeclaration {
pub id: LocalEvalId,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub gate: Option<Gate>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub mutex: Vec<MutexTag>,
#[serde(flatten)]
body: EvalBody,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct EvalBody {
profile: Profile,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
profile_variants: BTreeMap<ProfileVariantName, Profile>,
payload: EvalPayload,
pass_schema: Option<Map<String, Value>>,
fail_schema: Option<Map<String, Value>>,
}
impl EvalDeclaration {
pub fn profile(&self) -> &Profile {
&self.body.profile
}
pub fn payload(&self) -> Option<&EvalPayload> {
Some(&self.body.payload)
}
pub fn profile_variants(&self) -> &BTreeMap<ProfileVariantName, Profile> {
&self.body.profile_variants
}
pub fn pass_schema(&self) -> Option<&Map<String, Value>> {
self.body.pass_schema.as_ref()
}
pub fn fail_schema(&self) -> Option<&Map<String, Value>> {
self.body.fail_schema.as_ref()
}
#[cfg(test)]
pub(crate) fn profile_mut(&mut self) -> &mut Profile {
&mut self.body.profile
}
#[cfg(test)]
pub(crate) fn pass_schema_mut(&mut self) -> &mut Option<Map<String, Value>> {
&mut self.body.pass_schema
}
#[cfg(test)]
pub(crate) fn fail_schema_mut(&mut self) -> &mut Option<Map<String, Value>> {
&mut self.body.fail_schema
}
pub(crate) fn select_profile(&mut self, name: &ProfileVariantName) -> Result<(), String> {
self.body.profile = self
.body
.profile_variants
.get(name)
.ok_or_else(|| format!("Unknown profile variant: {name}"))?
.clone();
Ok(())
}
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
struct EvalFields {
title: String,
#[serde(default)]
gate: Option<Gate>,
#[serde(default, deserialize_with = "present")]
mutex: Option<Vec<MutexTag>>,
profile: Profile,
#[serde(default, deserialize_with = "present")]
profile_variants: Option<BTreeMap<ProfileVariantName, Profile>>,
#[serde(default, deserialize_with = "present")]
payload: Option<EvalPayload>,
#[serde(default, deserialize_with = "present")]
pass_schema: Option<Map<String, Value>>,
#[serde(default, deserialize_with = "present")]
fail_schema: Option<Map<String, Value>>,
}
impl EvalDeclaration {
fn new(id: LocalEvalId, fields: EvalFields) -> Result<Self, String> {
if fields.mutex.is_some()
&& !matches!(
fields.profile.kind(),
ProfileKind::Runtime | ProfileKind::Agent
)
{
return Err("mutex is supported only on runtime and agent Evals.".into());
}
let body = EvalBody {
profile: fields.profile,
profile_variants: fields.profile_variants.unwrap_or_default(),
payload: fields
.payload
.ok_or("Eval payload is required for runtime, agent and human profiles.")?,
pass_schema: fields.pass_schema,
fail_schema: fields.fail_schema,
};
Ok(Self {
id,
title: fields.title,
gate: fields.gate,
mutex: fields.mutex.unwrap_or_default(),
body,
})
}
}
impl EvalDeclaration {
fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
location
.child("title")
.check(text(&self.title, "Eval title"))?;
if let Some(payload) = self.payload() {
location
.child("payload")
.child("instruction")
.check(text(&payload.instruction, "Eval payload.instruction"))?;
}
location
.child("mutex")
.check(mutex::validate(&self.mutex))?;
self.profile().validate(&location.child("profile"))?;
for (key, schema) in [
("pass_schema", self.pass_schema()),
("fail_schema", self.fail_schema()),
] {
if let Some(schema) = schema {
location
.child(key)
.check(crate::agent::verdict::validate_schema(schema))?;
if self.profile().kind() == ProfileKind::Human {
location
.child(key)
.check(crate::review::check_schema(schema))?;
}
}
}
if self.profile_variants().len() > MAX_DECLARED_ITEMS {
return Err(location
.child("profile_variants")
.error("profile_variants must contain at most 64 named profiles."));
}
for (name, profile) in self.profile_variants() {
let variant = location.child("profile_variants").child(name);
location
.child("profile_variants")
.key(name)
.check(identifier(name, "Profile variant name"))?;
profile.validate(&variant)?;
if std::mem::discriminant(profile) != std::mem::discriminant(self.profile()) {
return Err(
variant.error("Profile variants must retain the declared reviewer kind.")
);
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(
rename_all(serialize = "camelCase", deserialize = "snake_case"),
deny_unknown_fields
)]
pub struct Views {
#[serde(default)]
pub agent_sessions: BTreeMap<ToolOperationName, ToolSession>,
#[serde(default)]
pub agent_tools: BTreeMap<ToolOperationName, AgentTool>,
#[serde(default)]
pub human_tools: BTreeMap<ToolOperationName, HumanTool>,
}
impl Views {
fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
for (name, session) in &self.agent_sessions {
let sessions = location.child("agent_sessions");
sessions
.key(name)
.check(identifier(name, "Tool session name"))?;
sessions
.child(name)
.child("start")
.check(session.start.validate())?;
sessions
.child(name)
.child("stop")
.check(session.stop.validate())?;
}
for (name, tool) in &self.agent_tools {
let tools = location.child("agent_tools");
if let AgentTool::Command(tool) = tool
&& let Some(session) = &tool.session
&& !self.agent_sessions.contains_key(session)
{
return Err(tools
.child(name)
.child("session")
.error(format!("Unknown tool session: {session}")));
}
tools.key(name).check(identifier(name, "Agent tool name"))?;
tools
.child(name)
.check(tool.validate().map_err(|e| format!("Tool {name}: {e}")))?;
}
for (name, tool) in &self.human_tools {
let tools = location.child("human_tools");
tools.key(name).check(identifier(name, "Human tool name"))?;
tools
.child(name)
.check(tool.validate().map_err(|e| format!("Tool {name}: {e}")))?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "Map<String, Value>", into = "Value")]
pub enum Fingerprint {
Graph,
Script {
command: String,
args: Vec<crate::scope::Argument>,
files: Vec<LogicalPath>,
timeout_ms: Option<std::time::Duration>,
},
Artifactsum {
files: Vec<LogicalPath>,
ignore: Vec<String>,
},
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ScriptForm {
script: ScriptFields,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
struct ScriptFields {
command: String,
args: Vec<crate::scope::Argument>,
#[serde(default)]
files: Vec<LogicalPath>,
#[serde(
default,
deserialize_with = "timeout",
serialize_with = "validation::milliseconds::serialize"
)]
timeout_ms: Option<std::time::Duration>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ArtifactsumForm {
#[serde(default = "owner_root")]
files: Vec<LogicalPath>,
#[serde(default)]
ignore: Vec<String>,
}
fn owner_root() -> Vec<LogicalPath> {
vec![".".parse().expect("owner root logical path")]
}
impl Default for Fingerprint {
fn default() -> Self {
Self::Artifactsum {
files: owner_root(),
ignore: Vec::new(),
}
}
}
fn default_fingerprint() -> Option<Fingerprint> {
Some(Fingerprint::default())
}
fn declared_fingerprint<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Fingerprint>, D::Error> {
use serde::de::Error;
match Value::deserialize(deserializer)? {
Value::Bool(false) => Ok(None),
Value::Object(object) => Fingerprint::try_from(object)
.map(Some)
.map_err(D::Error::custom),
_ => Err(D::Error::custom(
"fingerprint must be false or an object declaring artifactsum (files, ignore) or a script.",
)),
}
}
impl TryFrom<Map<String, Value>> for Fingerprint {
type Error = serde_json::Error;
fn try_from(object: Map<String, Value>) -> Result<Self, Self::Error> {
Ok(if object.contains_key("script") {
let ScriptForm {
script:
ScriptFields {
command,
args,
files,
timeout_ms,
},
} = serde_json::from_value(Value::Object(object))?;
Self::Script {
command,
args,
files,
timeout_ms,
}
} else {
if object.contains_key("dependencies") {
return Err(serde::de::Error::custom(
"fingerprint.dependencies was removed in 0.5: a fingerprint covers only its own Artifact, and an eval's reuse key adds the fingerprints of its children and referenced Artifacts. Remove the field.",
));
}
let ArtifactsumForm { files, ignore } = serde_json::from_value(Value::Object(object))?;
Self::Artifactsum { files, ignore }
})
}
}
impl From<Fingerprint> for Value {
fn from(fingerprint: Fingerprint) -> Self {
match fingerprint {
Fingerprint::Graph => serde_json::json!({"graph": true}),
Fingerprint::Script {
command,
args,
files,
timeout_ms,
} => serde_json::json!({
"script": {
"command": command,
"args": args,
"files": files,
"timeoutMs": timeout_ms.map(|value| value.as_millis()),
},
}),
Fingerprint::Artifactsum { files, ignore } => {
serde_json::json!({"files": files, "ignore": ignore})
}
}
}
}
impl Fingerprint {
pub fn kind(&self) -> crate::types::FingerprintKind {
match self {
Self::Graph => crate::types::FingerprintKind::Graph,
Self::Artifactsum { .. } => crate::types::FingerprintKind::Artifactsum,
Self::Script { .. } => crate::types::FingerprintKind::Script,
}
}
fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
match self {
Self::Graph => Ok(()),
Self::Script {
command,
args,
files,
..
} => {
location.child("script").check(script(command, args))?;
location
.child("script")
.child("files")
.check(paths(files, "fingerprint.script.files"))
}
Self::Artifactsum { files, ignore, .. } => {
if files.is_empty()
|| files.len() > MAX_DECLARED_ITEMS
|| files.iter().collect::<BTreeSet<_>>().len() != files.len()
{
return Err(location.child("files").error(
"fingerprint.files must contain 1–64 unique owner-relative paths.",
));
}
for file in files.iter().filter(|file| *file != ".") {
location.child("files").check(
path(file).map_err(|message| format!("fingerprint.files: {message}")),
)?;
}
location
.child("ignore")
.check(crate::cache::ignore_patterns(ignore))
}
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct ArtifactDeclaration {
pub name: ArtifactName,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, deserialize_with = "declared_evals")]
pub evals: Vec<EvalDeclaration>,
#[serde(default)]
pub views: Views,
#[serde(default, deserialize_with = "present")]
pub basis: Option<bool>,
#[serde(
default = "default_fingerprint",
deserialize_with = "declared_fingerprint"
)]
pub fingerprint: Option<Fingerprint>,
}
impl ArtifactDeclaration {
fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
location
.child("name")
.check(identifier(&self.name, "Artifact name"))?;
location.child("tags").check(tags(&self.tags))?;
for eval in &self.evals {
eval.validate(&location.child("evals").key(&eval.id))
.map_err(|message| format!("Eval {}: {message}", eval.id))?;
}
if self.basis == Some(true) && !self.evals.is_empty() {
return Err(location
.child("basis")
.error(format!("Basis Artifact {} cannot own Evals.", self.name)));
}
self.views.validate(&location.child("views"))?;
if let Some(fingerprint) = &self.fingerprint {
fingerprint.validate(&location.child("fingerprint"))?;
}
Ok(())
}
}
fn declared_evals<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<EvalDeclaration>, D::Error> {
use serde::de::Error;
BTreeMap::<String, EvalFields>::deserialize(deserializer)?
.into_iter()
.map(|(id, fields)| {
let id = id.parse().map_err(D::Error::custom)?;
EvalDeclaration::new(id, fields).map_err(D::Error::custom)
})
.collect()
}
pub fn parse_declaration(source: &str) -> Result<ArtifactDeclaration, String> {
let table = toml::de::DeTable::parse(source).map_err(|error| error.to_string())?;
let span = table.span();
let document = toml::Spanned::new(span, toml::de::DeValue::Table(table.into_inner()));
let location = Location::root(source, &document);
for (key, value) in document.get_ref().as_table().unwrap() {
reject_non_json_values(value, key.get_ref().as_ref(), source)?;
}
if document.get_ref().get("family").is_some() {
return Err(location
.child("family")
.error("family was removed in 0.9.0; declare each instance as its own Artifact."));
}
for key in ["stale_key", "stale"] {
if document.get_ref().get(key).is_some() {
return Err(location.child(key).error(format!(
"{key} was renamed to fingerprint: use fingerprint = {{ files = [\".\"], ignore = [] }} or [fingerprint.script]."
)));
}
}
if let Some(evals) = document
.get_ref()
.get("evals")
.and_then(|value| value.get_ref().as_table())
{
for (id, eval) in evals {
if eval.get_ref().get("result_check").is_some() {
return Err(location
.child("evals")
.child(id.get_ref())
.child("result_check")
.error(format!(
"Eval {}: result_check was removed in 0.6.0; remove it. The review's tool calls are in its saved session (artifactize session show).",
id.get_ref()
)));
}
}
}
validate_identity_locations(&location, &document)?;
if let Some(fingerprint) = document.get_ref().get("fingerprint")
&& let Some(fields) = fingerprint.get_ref().as_table()
{
let fingerprint_location = location.child("fingerprint");
if fields.contains_key("script") {
fingerprint_location.deserialize::<ScriptForm>()?;
} else if !fields.contains_key("dependencies") {
fingerprint_location.deserialize::<ArtifactsumForm>()?;
}
}
if let Some(tools) = document
.get_ref()
.get("views")
.and_then(|views| views.get_ref().get("agent_tools"))
.and_then(|tools| tools.get_ref().as_table())
{
for (name, tool) in tools {
if tool.get_ref().as_table().is_some() {
let tool_location = location
.child("views")
.child("agent_tools")
.child(name.get_ref());
if tool.get_ref().get("builtin").is_some() {
tool_location.deserialize::<BuiltinTool>()?;
} else {
tool_location.deserialize::<CommandTool>()?;
}
}
}
}
if let Some(tools) = document
.get_ref()
.get("views")
.and_then(|views| views.get_ref().get("human_tools"))
.and_then(|tools| tools.get_ref().as_table())
{
for (name, _) in tools {
location
.child("views")
.child("human_tools")
.child(name.get_ref())
.deserialize::<HumanTool>()?;
}
}
if let Some(sessions) = document
.get_ref()
.get("views")
.and_then(|views| views.get_ref().get("agent_sessions"))
.and_then(|sessions| sessions.get_ref().as_table())
{
for (name, _) in sessions {
location
.child("views")
.child("agent_sessions")
.child(name.get_ref())
.deserialize::<ToolSession>()?;
}
}
let declaration =
ArtifactDeclaration::deserialize(toml::de::ValueDeserializer::from(document.clone()))
.map_err(|mut error| {
error.set_input(Some(source));
error.to_string()
})?;
declaration.validate(&location)?;
Ok(declaration)
}
fn validate_identity_locations(
location: &Location<'_, '_>,
document: &toml::Spanned<toml::de::DeValue<'_>>,
) -> Result<(), String> {
use toml::de::DeValue;
if let Some(name) = document.get_ref().get("name")
&& let DeValue::String(name) = name.get_ref()
{
location
.child("name")
.check(identifier(name, "Artifact name"))?;
}
if let Some(evals) = document
.get_ref()
.get("evals")
.and_then(|value| value.get_ref().as_table())
{
for (id, eval) in evals {
let eval_location = location.child("evals").key(id.get_ref());
eval_location.check(identifier(id.get_ref(), "Eval id"))?;
if let Some(model) = eval
.get_ref()
.get("profile")
.and_then(|profile| profile.get_ref().get("model"))
&& let DeValue::String(model) = model.get_ref()
{
eval_location
.child("profile")
.child("model")
.check(text(model, "Agent profile model"))?;
}
if let Some(variants) = eval
.get_ref()
.get("profile_variants")
.and_then(|value| value.get_ref().as_table())
{
for (name, profile) in variants {
let variant = eval_location.child("profile_variants").key(name.get_ref());
variant.check(identifier(name.get_ref(), "Profile variant name"))?;
if let Some(model) = profile.get_ref().get("model")
&& let DeValue::String(model) = model.get_ref()
{
variant
.child("model")
.check(text(model, "Agent profile model"))?;
}
}
}
}
}
Ok(())
}
fn reject_non_json_values(
value: &toml::Spanned<toml::de::DeValue<'_>>,
path: &str,
source: &str,
) -> Result<(), String> {
use toml::de::DeValue;
let diagnostic = |message| location::positioned(source, value.span().start, path, message);
match value.get_ref() {
DeValue::Datetime(_) => Err(diagnostic(
"TOML date/time values have no JSON equivalent; use a quoted string.",
)),
DeValue::Float(number)
if matches!(
number.as_str(),
"inf" | "+inf" | "-inf" | "nan" | "+nan" | "-nan"
) =>
{
Err(diagnostic(
"TOML non-finite floats have no JSON equivalent.",
))
}
DeValue::Array(items) => {
for (index, item) in items.iter().enumerate() {
reject_non_json_values(item, &format!("{path}[{index}]"), source)?;
}
Ok(())
}
DeValue::Table(table) => {
for (key, value) in table {
reject_non_json_values(value, &format!("{path}.{}", key.get_ref()), source)?;
}
Ok(())
}
_ => Ok(()),
}
}
pub use artifactize_tools::scope::ArtifactKind;
fn tool_children(
children: &BTreeMap<ChildPrefix, ArtifactName>,
) -> BTreeMap<artifactize_tools::scope::ChildPrefix, artifactize_tools::scope::ArtifactId> {
children
.iter()
.map(|(path, name)| {
(
artifactize_tools::scope::ChildPrefix::new(path.as_ref())
.expect("validated child prefix"),
crate::scope::tool_id(name),
)
})
.collect()
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Artifact {
#[serde(with = "crate::platform::path_serde")]
pub path: PathBuf,
pub kind: ArtifactKind,
pub children: BTreeMap<ChildPrefix, ArtifactName>,
pub name: ArtifactName,
pub tags: Vec<String>,
pub views: Views,
pub basis: Option<bool>,
pub fingerprint: Option<Fingerprint>,
}
impl Artifact {
pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Artifact {
artifactize_tools::scope::Artifact {
path: self.path.clone(),
kind: self.kind,
name: self.name.to_string(),
children: tool_children(&self.children),
}
}
pub fn folder(&self) -> &Path {
match self.kind {
ArtifactKind::Folder | ArtifactKind::Graph => &self.path,
ArtifactKind::File => self.path.parent().unwrap_or(Path::new("")),
}
}
pub fn file_name(&self) -> Option<&str> {
(self.kind == ArtifactKind::File)
.then(|| self.path.file_name().and_then(|name| name.to_str()))
.flatten()
}
pub fn declaration_path(&self) -> PathBuf {
match self.kind {
ArtifactKind::Graph => PathBuf::from(".artf"),
ArtifactKind::Folder => logical_join(&self.path, std::ffi::OsStr::new(CONFIG_FILE)),
ArtifactKind::File => {
let mut path = self.path.as_os_str().to_owned();
path.push(".artf");
PathBuf::from(path)
}
}
}
}
#[derive(Debug, Serialize)]
pub struct Eval {
pub id: EvalId,
pub target: ArtifactName,
pub references: BTreeMap<crate::config::ReferenceName, ArtifactName>,
pub deps: Vec<ArtifactName>,
pub declaration: EvalDeclaration,
#[serde(skip)]
pub variant: Option<ProfileVariantName>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewRequirement {
Basis,
Unreviewed,
Evals,
}
#[derive(Debug)]
pub struct RepoConfig {
pub root: PathBuf,
pub artifacts: BTreeMap<ArtifactName, Artifact>,
pub evals: Vec<Eval>,
pub relations: Vec<crate::scope::Relation>,
}
impl RepoConfig {
pub fn review_requirement(&self, artifact_id: &str) -> Option<ReviewRequirement> {
let artifact = self.artifacts.get(artifact_id)?;
Some(if artifact.basis == Some(true) {
ReviewRequirement::Basis
} else if self.evals.iter().any(|eval| eval.target == artifact_id) {
ReviewRequirement::Evals
} else {
ReviewRequirement::Unreviewed
})
}
}
#[cfg(test)]
mod tests;