use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use thiserror::Error;
mod location;
mod stored;
mod tools;
pub(crate) mod validation;
pub use stored::{Field, StoredPayload, StoredProfile};
pub use tools::{
AgentTool, Builtin, BuiltinTool, CommandTool, HumanTool, HumanToolKind, ToolProtocol,
};
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("{path}: {message}")]
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 = fs::read_to_string(&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 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, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
pub enum Profile {
Agent {
backend: Backend,
model: String,
#[serde(default, deserialize_with = "present")]
reasoning: Option<String>,
#[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 {},
Dependency {
#[serde(rename(serialize = "dependsOn", deserialize = "depends_on"))]
depends_on: Vec<String>,
},
Runtime {
command: String,
args: Vec<String>,
#[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))?;
}
Ok(())
}
Self::Human {} => Ok(()),
Self::Dependency { depends_on } => {
if depends_on.is_empty()
|| depends_on.len() > MAX_DECLARED_ITEMS
|| depends_on.iter().collect::<BTreeSet<_>>().len() != depends_on.len()
{
return Err(location.child("depends_on").error(
"Dependency profile depends_on must contain 1–64 unique Artifact names.",
));
}
for name in depends_on {
location
.child("depends_on")
.check(identifier(name, "Dependency Artifact name"))?;
}
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::Dependency { .. } => ProfileKind::Dependency,
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,
Dependency,
}
impl ProfileKind {
pub fn name(self) -> &'static str {
match self {
Self::Runtime => "runtime",
Self::Agent => "agent",
Self::Human => "human",
Self::Dependency => "dependency",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalPayload {
pub instruction: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", try_from = "EvalFields")]
pub struct EvalDeclaration {
pub id: String,
pub title: String,
pub profile: Profile,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub profile_variants: BTreeMap<String, Profile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<EvalPayload>,
#[serde(default, deserialize_with = "present")]
pub pass_schema: Option<Map<String, Value>>,
#[serde(default, deserialize_with = "present")]
pub fail_schema: Option<Map<String, Value>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
struct EvalFields {
title: String,
profile: Profile,
#[serde(default, deserialize_with = "present")]
profile_variants: Option<BTreeMap<String, 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 TryFrom<EvalFields> for EvalDeclaration {
type Error = String;
fn try_from(fields: EvalFields) -> Result<Self, Self::Error> {
if matches!(fields.profile, Profile::Dependency { .. }) {
if fields.payload.is_some()
|| fields.pass_schema.is_some()
|| fields.fail_schema.is_some()
|| fields.profile_variants.is_some()
{
return Err(
"Dependency Evals cannot declare payload, pass_schema, fail_schema or profile_variants."
.into(),
);
}
} else if fields.payload.is_none() {
return Err("Eval payload is required for runtime, agent and human profiles.".into());
}
Ok(Self {
id: String::new(),
title: fields.title,
profile: fields.profile,
profile_variants: fields.profile_variants.unwrap_or_default(),
payload: fields.payload,
pass_schema: fields.pass_schema,
fail_schema: fields.fail_schema,
})
}
}
impl EvalDeclaration {
fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
location.check(identifier(&self.id, "Eval id"))?;
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"))?;
}
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_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_tools: BTreeMap<String, AgentTool>,
#[serde(default)]
pub human_tools: BTreeMap<String, HumanTool>,
}
impl Views {
fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
for (name, tool) in &self.agent_tools {
let tools = location.child("agent_tools");
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 {
Script {
command: String,
args: Vec<String>,
files: Vec<String>,
timeout_ms: Option<std::time::Duration>,
},
Artifactsum {
files: Vec<String>,
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<String>,
#[serde(default)]
files: Vec<String>,
#[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<String>,
#[serde(default)]
ignore: Vec<String>,
}
fn owner_root() -> Vec<String> {
vec![".".into()]
}
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 mounts, 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::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 {
fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
match self {
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, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DependencyGates {
Green,
Ignore,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(
rename_all(serialize = "camelCase", deserialize = "snake_case"),
deny_unknown_fields
)]
pub struct ReviewPolicy {
#[serde(default, deserialize_with = "present")]
pub dependency_gates: Option<DependencyGates>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct ArtifactDeclaration {
pub name: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, deserialize_with = "declared_evals")]
pub evals: Vec<EvalDeclaration>,
#[serde(default)]
pub views: Views,
#[serde(default)]
pub mounts: BTreeMap<String, String>,
#[serde(default, deserialize_with = "present")]
pub basis: Option<bool>,
#[serde(
default = "default_fingerprint",
deserialize_with = "declared_fingerprint"
)]
pub fingerprint: Option<Fingerprint>,
#[serde(default, deserialize_with = "present")]
pub review_policy: Option<ReviewPolicy>,
}
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"))?;
for (alias, target) in &self.mounts {
let mounts = location.child("mounts");
mounts.key(alias).check(identifier(alias, "Mount alias"))?;
mounts
.child(alias)
.check(identifier(target, "Mount target"))?;
}
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> {
let evals = BTreeMap::<String, EvalDeclaration>::deserialize(deserializer)?;
Ok(evals
.into_iter()
.map(|(id, mut eval)| {
eval.id = id;
eval
})
.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()
)));
}
}
}
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>()?;
}
}
}
}
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 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(()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ArtifactKind {
Folder,
File,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Artifact {
pub path: PathBuf,
pub kind: ArtifactKind,
pub children: BTreeMap<String, String>,
pub name: String,
pub tags: Vec<String>,
pub views: Views,
pub mounts: BTreeMap<String, String>,
pub basis: Option<bool>,
pub fingerprint: Option<Fingerprint>,
pub review_policy: Option<ReviewPolicy>,
}
impl Artifact {
pub fn folder(&self) -> &Path {
match self.kind {
ArtifactKind::Folder => &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::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: String,
pub target: String,
pub references: BTreeMap<String, String>,
pub deps: Vec<String>,
pub declaration: EvalDeclaration,
#[serde(skip)]
pub variant: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewRequirement {
Basis,
Unreviewed,
Evals,
}
#[derive(Debug)]
pub struct RepoConfig {
pub root: PathBuf,
pub artifacts: BTreeMap<String, 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
})
}
}
pub fn read_workspace_config(repo: &Path) -> Result<RepoConfig, ConfigError> {
let root =
crate::platform::canonicalize(repo).map_err(|error| ConfigError::new(repo, error))?;
let mut config = RepoConfig {
root,
artifacts: BTreeMap::new(),
evals: Vec::new(),
relations: Vec::new(),
};
let ignored = discovery_ignore(&config.root)?;
let mut pending = vec![(PathBuf::new(), None::<String>)];
while let Some((relative, mut owner)) = pending.pop() {
let directory = config.root.join(&relative);
let entries = fs::read_dir(&directory)
.and_then(|entries| entries.collect::<Result<Vec<_>, _>>())
.map_err(|error| ConfigError::new(&directory, error))?;
let mut entries = entries;
entries.sort_by_key(|entry| entry.file_name());
if let Some(legacy) = entries
.iter()
.find(|entry| entry.file_name() == "artifactize.json")
{
let file = legacy.path();
let kind = legacy
.file_type()
.map_err(|error| ConfigError::new(&file, error))?;
if !ignored.matched(&file, kind.is_dir()).is_ignore() && !kind.is_dir() {
return Err(ConfigError::new(
file,
"artifactize.json is no longer read; declare this Artifact in index.artf (TOML).",
));
}
}
if let Some(marker) = entries
.iter()
.find(|entry| entry.file_name() == CONFIG_FILE)
{
let file = marker.path();
let kind = marker
.file_type()
.map_err(|error| ConfigError::new(&file, error))?;
if !kind.is_file() {
return Err(ConfigError::new(file, "index.artf must be a regular file."));
}
let source =
fs::read_to_string(&file).map_err(|error| ConfigError::new(&file, error))?;
let declaration =
parse_declaration(&source).map_err(|error| ConfigError::new(&file, error))?;
if !relative.as_os_str().is_empty() && declaration.review_policy.is_some() {
return Err(ConfigError::declaration(
&file,
&["review_policy"],
"review_policy belongs only to the repository root index.artf.",
));
}
let name = declaration.name.clone();
if let Some(parent) = &owner {
let parent = config.artifacts.get_mut(parent).unwrap();
let child = relative.strip_prefix(&parent.path).unwrap();
let child = child
.to_str()
.ok_or_else(|| ConfigError::new(&file, "Artifact paths must be UTF-8."))?;
parent.children.insert(child.to_owned(), name.clone());
}
insert_artifact(
&mut config,
relative.clone(),
ArtifactKind::Folder,
declaration,
&file,
)?;
owner = Some(name);
}
for marker in &entries {
if !ignored.matched(marker.path(), false).is_ignore()
&& marker
.file_name()
.as_encoded_bytes()
.ends_with(b".artf.artf")
{
return Err(ConfigError::new(
marker.path(),
"File Artifact target must not be a declaration (.artf).",
));
}
}
for marker in &entries {
if ignored.matched(marker.path(), false).is_ignore() {
continue;
}
let filename = marker.file_name();
if !filename.as_encoded_bytes().ends_with(b".artf") {
continue;
}
let Some(filename) = filename.to_str() else {
return Err(ConfigError::new(
marker.path(),
"Artifact paths must be UTF-8.",
));
};
let Some(target) = filename.strip_suffix(".artf") else {
continue;
};
if filename == CONFIG_FILE {
continue;
}
let file = marker.path();
if !marker
.file_type()
.map_err(|error| ConfigError::new(&file, error))?
.is_file()
{
return Err(ConfigError::new(
&file,
"File Artifact declaration must be a regular file.",
));
}
if target.is_empty() || target.ends_with(".artf") {
return Err(ConfigError::new(
&file,
"File Artifact target must not be a declaration (.artf).",
));
}
path(target).map_err(|error| ConfigError::new(&file, error))?;
if target.contains(':') {
return Err(ConfigError::new(
&file,
"File Artifact target name must not contain ':'; scoped tool paths do not support colons.",
));
}
let target_path = directory.join(target);
let metadata = fs::symlink_metadata(&target_path).map_err(|error| {
ConfigError::new(
&file,
if error.kind() == std::io::ErrorKind::NotFound {
format!("File Artifact target {target} is missing.")
} else {
format!("Cannot inspect File Artifact target {target}: {error}.")
},
)
})?;
if !metadata.is_file() || metadata.is_symlink() {
return Err(ConfigError::new(
&file,
format!(
"File Artifact target {target} must be a regular file, not a symlink, directory or special file."
),
));
}
let source =
fs::read_to_string(&file).map_err(|error| ConfigError::new(&file, error))?;
let mut declaration =
parse_declaration(&source).map_err(|error| ConfigError::new(&file, error))?;
if declaration.review_policy.is_some() {
return Err(ConfigError::declaration(
&file,
&["review_policy"],
"review_policy belongs only to the repository root index.artf.",
));
}
let value = toml::de::DeTable::parse(&source)
.map_err(|error| ConfigError::new(&file, error))?;
if value.get_ref().get("fingerprint").is_none() {
declaration.fingerprint = Some(Fingerprint::Artifactsum {
files: vec![target.into()],
ignore: Vec::new(),
});
}
if let Some(Fingerprint::Artifactsum { files, .. }) = &declaration.fingerprint {
if value
.get_ref()
.get("fingerprint")
.and_then(|value| value.get_ref().get("ignore"))
.is_some()
{
return Err(ConfigError::declaration(
&file,
&["fingerprint", "ignore"],
"fingerprint.ignore is not supported for a file Artifact.",
));
}
if files.iter().any(|input| input != target) {
return Err(ConfigError::declaration(
&file,
&["fingerprint", "files"],
format!(
"fingerprint.files for a file Artifact may name only its target {target}."
),
));
}
}
insert_artifact(
&mut config,
logical_join(&relative, std::ffi::OsStr::new(target)),
ArtifactKind::File,
declaration,
&file,
)?;
}
for entry in entries.into_iter().rev() {
let kind = entry
.file_type()
.map_err(|error| ConfigError::new(entry.path(), error))?;
if kind.is_dir()
&& entry.file_name() != ".git"
&& entry.file_name() != "node_modules"
&& !ignored.matched(entry.path(), true).is_ignore()
{
pending.push((logical_join(&relative, &entry.file_name()), owner.clone()));
}
}
}
if config.artifacts.is_empty() {
return Err(ConfigError::new(
&config.root,
"Workspace must contain at least one .artf Artifact.",
));
}
crate::scope::resolve_config(&mut config)?;
Ok(config)
}
fn insert_artifact(
config: &mut RepoConfig,
path: PathBuf,
kind: ArtifactKind,
declaration: ArtifactDeclaration,
file: &Path,
) -> Result<(), ConfigError> {
let ArtifactDeclaration {
name,
tags,
evals,
views,
mounts,
basis,
fingerprint,
review_policy,
} = declaration;
if config.artifacts.contains_key(&name) {
return Err(ConfigError::declaration(
file,
&["name"],
format!("Duplicate Artifact name: {name}."),
));
}
for declaration in evals {
config.evals.push(Eval {
id: format!("{name}/{}", declaration.id),
target: name.clone(),
references: BTreeMap::new(),
deps: Vec::new(),
declaration,
variant: None,
});
}
config.artifacts.insert(
name.clone(),
Artifact {
path,
kind,
children: BTreeMap::new(),
name,
tags,
views,
mounts,
basis,
fingerprint,
review_policy,
},
);
Ok(())
}
fn discovery_ignore(root: &Path) -> Result<ignore::gitignore::Gitignore, ConfigError> {
let legacy = root.join(".artifactizeignore");
if fs::symlink_metadata(&legacy).is_ok() {
return Err(ConfigError::new(
legacy,
".artifactizeignore was renamed to .artfignore.",
));
}
let file = root.join(IGNORE_FILE);
let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
if fs::symlink_metadata(&file).is_ok_and(|metadata| metadata.is_file())
&& let Some(error) = builder.add(&file)
{
return Err(ConfigError::new(&file, error));
}
builder
.build()
.map_err(|error| ConfigError::new(&file, error))
}
fn logical_join(parent: &Path, name: &std::ffi::OsStr) -> PathBuf {
if parent.as_os_str().is_empty() {
return PathBuf::from(name);
}
let mut path = parent.as_os_str().to_owned();
path.push("/");
path.push(name);
PathBuf::from(path)
}
#[cfg(test)]
mod tests;