use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::config::{Artifact, ArtifactName, ConfigError, Eval, Fingerprint, Profile, RepoConfig};
mod human;
mod instruction;
pub(crate) use human::{
builtin_args, human_arguments, resolve_human_argv, validate_human_argument,
};
pub use instruction::instruction_references;
pub use artifactize_tools::scope::{ArtifactId, ScopeError, ScopedPath, scoped_path};
pub(crate) use artifactize_tools::scope::{logical_path, open_child, open_scoped};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Relation {
pub source: ArtifactName,
pub target: ArtifactName,
#[serde(flatten)]
pub kind: RelationKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum RelationKind {
Child {
path: crate::config::ChildPrefix,
},
Mount {
alias: crate::config::MountAlias,
},
Dependency {
#[serde(rename = "evalId")]
eval_id: crate::config::EvalId,
name: crate::config::ReferenceName,
},
Instruction {
#[serde(rename = "evalId")]
eval_id: crate::config::EvalId,
name: crate::config::ReferenceName,
},
#[serde(rename = "argv")]
Argument {
#[serde(rename = "evalId")]
eval_id: crate::config::EvalId,
index: usize,
name: crate::config::ReferenceName,
path: String,
},
}
#[derive(Debug)]
pub struct Scope<'a> {
pub artifacts: BTreeMap<ArtifactName, &'a Artifact>,
}
impl Scope<'_> {
pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Scope {
artifactize_tools::scope::Scope {
artifacts: self
.artifacts
.iter()
.map(|(id, artifact)| (tool_id(id), artifact.tool_scope()))
.collect(),
}
}
pub fn resolve_path(&self, artifact_id: &str, path: &str) -> Result<ScopedPath, ScopeError> {
self.tool_scope()
.resolve_path(&ArtifactId::new(artifact_id)?, path)
}
pub fn resolve_input(
&self,
root: &Path,
artifact_id: &str,
path: &str,
) -> Result<PathBuf, ScopeError> {
self.tool_scope()
.resolve_input(root, &ArtifactId::new(artifact_id)?, path)
}
}
pub(crate) fn tool_id(name: &ArtifactName) -> ArtifactId {
ArtifactId::new(name.as_str()).expect("configuration validates Artifact names")
}
pub(crate) fn validate_file_target(root: &Path, artifact: &Artifact) -> Result<(), ScopeError> {
if artifact.file_name().is_some() {
artifact
.path
.to_str()
.ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
let target = crate::platform::path_text(&artifact.path);
let file = open_scoped(root, &target).map_err(|error| {
ScopeError(format!(
"File Artifact {} target {} is unavailable: {error}",
artifact.name,
crate::platform::path_text(&artifact.path)
))
})?;
if !file
.metadata()
.map_err(|error| ScopeError(error.to_string()))?
.is_file()
{
return Err(ScopeError(format!(
"File Artifact {} target {} must remain a regular file.",
artifact.name,
crate::platform::path_text(&artifact.path)
)));
}
}
Ok(())
}
fn reference_path(config: &RepoConfig, id: &str, path: &str) -> Result<(), ScopeError> {
if config.artifacts[id].file_name().is_some() && !path.is_empty() {
return Err(ScopeError(format!(
"File Artifact {{{id}}} cannot have a /path suffix."
)));
}
Ok(())
}
pub fn artifact_scope<'a>(config: &'a RepoConfig, roots: &[&str]) -> Result<Scope<'a>, ScopeError> {
let mut artifacts = BTreeMap::new();
let mut pending = roots.to_vec();
while let Some(id) = pending.pop() {
let (id, artifact) = config
.artifacts
.get_key_value(id)
.ok_or_else(|| ScopeError(format!("Unknown Artifact: {id}")))?;
if artifacts.insert(id.clone(), artifact).is_some() {
continue;
}
pending.extend(artifact.children.values().map(ArtifactName::as_str));
pending.extend(artifact.mounts.values().map(ArtifactName::as_str));
}
Ok(Scope { artifacts })
}
pub(crate) fn executable(
root: &Path,
scope: &Scope<'_>,
owner: &str,
command: &str,
) -> Result<std::ffi::OsString, String> {
let artifact = scope.artifacts[owner];
let cwd = scoped_path(root, artifact.folder()).map_err(|error| error.to_string())?;
let program = if let Some(relative) = crate::platform::program::folder_relative(command) {
let mounted = artifact
.mounts
.contains_key(relative.split('/').next().unwrap_or(""));
let physical = if artifact.file_name().is_some() && !mounted {
artifactize_tools::scope::logical_path(relative).map_err(|error| error.to_string())?;
cwd.join(relative)
} else {
let resolved = scope
.resolve_path(owner, relative)
.map_err(|error| error.to_string())?;
root.join(scope.artifacts[resolved.artifact_id.as_str()].folder())
.join(resolved.path)
};
crate::platform::program::reject_link_ancestors(&physical)?;
let mut failure = None;
crate::platform::program::candidates(Path::new(relative))
.into_iter()
.find_map(|candidate| {
let candidate = if candidate.as_path() != Path::new(relative) {
executable_spelling(root, scope, owner, &cwd, &candidate, mounted)?
} else {
candidate
};
let resolved = if artifact.file_name().is_some() && !mounted {
scoped_path(&cwd, &candidate)
} else {
scope.resolve_input(
root,
owner,
&artifactize_tools::scope::logical_from_native(&candidate)?,
)
};
match resolved {
Ok(program) if program.is_file() => Some(program),
Ok(_) => None,
Err(error) => {
failure.get_or_insert_with(|| error.to_string());
None
}
}
})
.ok_or_else(|| {
failure.unwrap_or_else(|| "Executable path is unavailable or outside scope.".into())
})?
} else {
PathBuf::from(command)
};
Ok(program.into_os_string())
}
fn executable_spelling(
root: &Path,
scope: &Scope<'_>,
owner: &str,
cwd: &Path,
candidate: &Path,
mounted: bool,
) -> Option<PathBuf> {
let parent = candidate.parent().unwrap_or(Path::new(""));
let directory = if scope.artifacts[owner].file_name().is_some() && !mounted {
scoped_path(cwd, parent).ok()?
} else {
scope.resolve_input(root, owner, parent.to_str()?).ok()?
};
crate::platform::program::suffix_spelling(&directory, candidate)
}
pub fn eval_scope<'a>(config: &'a RepoConfig, eval: &Eval) -> Result<Scope<'a>, ScopeError> {
let roots: Vec<_> = std::iter::once(eval.target.as_str())
.chain(eval.deps.iter().map(ArtifactName::as_str))
.collect();
artifact_scope(config, &roots)
}
pub fn argv_scope<'a>(
config: &'a RepoConfig,
owner: &str,
args: &[Argument],
) -> Result<Scope<'a>, ScopeError> {
let mut roots = vec![owner];
for argument in args {
if let Argument::Reference { name, .. } = argument {
roots.push(reference_target(config, owner, name)?);
}
}
artifact_scope(config, &roots)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstructionPart {
Text(String),
Artifact(ArtifactName),
}
pub fn parse_artifact_instruction(
source: &str,
scope: &Scope<'_>,
references: &BTreeMap<crate::config::ReferenceName, ArtifactName>,
) -> Vec<InstructionPart> {
let mut parts = Vec::new();
let mut start = 0;
for reference in instruction::references(source) {
let id = references
.get(&reference.name)
.map_or(reference.name.as_str(), ArtifactName::as_str);
if !scope.artifacts.contains_key(id) {
continue;
}
if start < reference.start {
parts.push(InstructionPart::Text(source[start..reference.start].into()));
}
parts.push(InstructionPart::Artifact(scope.artifacts[id].name.clone()));
start = reference.end;
}
if start < source.len() {
parts.push(InstructionPart::Text(source[start..].into()));
}
parts
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Argument {
Literal(String),
Reference {
prefix: String,
name: crate::config::ReferenceName,
path: String,
},
}
impl std::str::FromStr for Argument {
type Err = ScopeError;
fn from_str(argument: &str) -> Result<Self, Self::Err> {
let references = instruction::references(argument);
let Some(reference) = references.first() else {
return Ok(Self::Literal(argument.to_owned()));
};
let prefix = &argument[..reference.start];
let flag = prefix
.strip_prefix("--")
.and_then(|flag| flag.strip_suffix('='))
.is_some_and(|flag| {
!flag.is_empty()
&& flag
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
});
if references.len() != 1 || (!prefix.is_empty() && !flag) {
return Err(ScopeError(
"Artifact arguments must be {name}[/path] or --flag={name}[/path].".into(),
));
}
let suffix = &argument[reference.end..];
let path = if suffix.is_empty() {
""
} else {
suffix
.strip_prefix('/')
.filter(|path| !path.is_empty())
.ok_or_else(|| {
ScopeError("Artifact argument suffix must be a nonempty /path.".into())
})?
};
logical_path(path)?;
Ok(Self::Reference {
prefix: prefix.to_owned(),
name: reference.name.clone(),
path: path.to_owned(),
})
}
}
impl std::fmt::Display for Argument {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Literal(text) => f.write_str(text),
Self::Reference { prefix, name, path } => {
write!(f, "{prefix}{{{name}}}")?;
if !path.is_empty() {
write!(f, "/{path}")?;
}
Ok(())
}
}
}
}
impl TryFrom<String> for Argument {
type Error = ScopeError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
impl From<Argument> for String {
fn from(argument: Argument) -> Self {
argument.to_string()
}
}
impl Serialize for Argument {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> serde::Deserialize<'de> for Argument {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
String::deserialize(deserializer)?
.parse()
.map_err(|failure: ScopeError| D::Error::custom(failure.0))
}
}
impl From<&str> for Argument {
fn from(value: &str) -> Self {
value.parse().expect("a valid declared argument")
}
}
impl PartialEq<str> for Argument {
fn eq(&self, other: &str) -> bool {
other.parse::<Self>().is_ok_and(|parsed| *self == parsed)
}
}
impl PartialEq<&str> for Argument {
fn eq(&self, other: &&str) -> bool {
self == *other
}
}
impl PartialEq<Argument> for str {
fn eq(&self, other: &Argument) -> bool {
other == self
}
}
impl PartialEq<Argument> for String {
fn eq(&self, other: &Argument) -> bool {
other == self.as_str()
}
}
impl PartialEq<String> for Argument {
fn eq(&self, other: &String) -> bool {
self == other.as_str()
}
}
fn reference_target<'a>(
config: &'a RepoConfig,
owner: &str,
name: &str,
) -> Result<&'a ArtifactName, ScopeError> {
let artifact = config
.artifacts
.get(owner)
.ok_or_else(|| ScopeError(format!("Unknown Artifact: {owner}")))?;
let target = artifact.mounts.get(name).map_or(name, ArtifactName::as_str);
config
.artifacts
.get_key_value(target)
.map(|(id, _)| id)
.ok_or_else(|| {
ScopeError(format!(
"Unknown Artifact reference {{{name}}}. Escape literal braces with a backslash."
))
})
}
pub fn resolve_argv(
config: &RepoConfig,
scope: &Scope<'_>,
owner: &str,
args: &[Argument],
) -> Result<Vec<String>, ScopeError> {
args.iter()
.map(|argument| {
let Argument::Reference { prefix, name, path } = argument else {
return Ok(argument.to_string());
};
let id = reference_target(config, owner, name)?;
reference_path(config, id, path)?;
let resolved = scope.resolve_input(&config.root, id, path)?;
resolved
.to_str()
.ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
let resolved = crate::platform::path_text(&resolved);
Ok(format!("{prefix}{resolved}"))
})
.collect()
}
pub(crate) fn resolve_config(config: &mut RepoConfig) -> Result<(), ConfigError> {
let mut relations = Vec::new();
for (id, artifact) in &config.artifacts {
let error = |keys: &[&str], message: String| {
ConfigError::declaration(config.root.join(artifact.declaration_path()), keys, message)
};
for (name, tool) in &artifact.views.agent_tools {
if let crate::config::AgentTool::Builtin(tool) = tool
&& let Some(args) = &tool.args
{
let scope = artifact_scope(config, &[id.as_str()]).map_err(|failure| {
error(&["views", "agent_tools", name, "args"], failure.to_string())
})?;
let owner = ArtifactId::new(id.as_str()).map_err(|failure| {
error(&["views", "agent_tools", name, "args"], failure.to_string())
})?;
artifactize_tools::builtin::validate_target(
tool.builtin,
args,
&config.root,
&scope.tool_scope(),
&owner,
)
.map_err(|failure| error(&["views", "agent_tools", name, "args"], failure))?;
}
if let crate::config::AgentTool::Command(tool) = tool
&& tool.protocol == crate::config::ToolProtocol::Json
{
for argument in &tool.args {
if let Argument::Reference {
name: ref_name,
path,
..
} = argument
{
let target = reference_target(config, id, ref_name).map_err(|failure| {
error(
&["views", "agent_tools", name, "args"],
format!("Agent tool {name}: {failure}"),
)
})?;
reference_path(config, target, path).map_err(|failure| {
error(
&["views", "agent_tools", name, "args"],
format!("Agent tool {name}: {failure}"),
)
})?;
}
}
}
}
if let Some(Fingerprint::Script { args, .. }) = &artifact.fingerprint {
for argument in args {
if let Argument::Reference {
name: ref_name,
path,
..
} = argument
{
let target = reference_target(config, id, ref_name).map_err(|failure| {
error(
&["fingerprint", "script", "args"],
format!("fingerprint.script: {failure}"),
)
})?;
reference_path(config, target, path).map_err(|failure| {
error(
&["fingerprint", "script", "args"],
format!("fingerprint.script: {failure}"),
)
})?;
}
}
}
for (path, source) in &artifact.children {
relations.push(Relation {
source: source.clone(),
target: id.clone(),
kind: RelationKind::Child { path: path.clone() },
});
}
for (alias, source) in &artifact.mounts {
if !config.artifacts.contains_key(source) {
return Err(error(
&["mounts", alias],
format!("Unknown mount target {source} in {id}."),
));
}
if config.artifacts.contains_key(alias.as_str()) && alias.as_str() != source.as_str() {
return Err(error(
&["mounts", alias],
format!("Ambiguous mount alias {alias} in {id}."),
));
}
if artifact.file_name().is_some() {
if artifact.file_name() == Some(alias.as_str()) {
return Err(error(
&["mounts", alias],
format!("Mount {id}/{alias} conflicts with the target file."),
));
}
} else {
match crate::platform::entry_exists(
&config.root.join(artifact.folder()).join(alias.as_str()),
) {
Ok(true) => {
return Err(error(
&["mounts", alias],
format!("Mount {id}/{alias} conflicts with a physical entry."),
));
}
Ok(false) => {}
Err(failure) => return Err(error(&["mounts", alias], failure.to_string())),
}
}
relations.push(Relation {
source: source.clone(),
target: id.clone(),
kind: RelationKind::Mount {
alias: alias.clone(),
},
});
}
}
for (id, artifact) in &config.artifacts {
let Some(Fingerprint::Artifactsum { files: inputs, .. }) = &artifact.fingerprint else {
continue;
};
let error = |message: String| {
ConfigError::declaration(
config.root.join(artifact.declaration_path()),
&["fingerprint", "files"],
format!("fingerprint.files: {message}"),
)
};
let scope = artifact_scope(config, &[id]).map_err(|failure| error(failure.0))?;
for input in inputs.iter().filter(|input| *input != ".") {
if input.ends_with(".artf")
&& !crate::platform::path_kind(&config.root.join(artifact.folder()).join(input))
.is_ok_and(|kind| kind == crate::platform::FileKind::Directory)
{
return Err(error(
"Artifact declarations (*.artf) cannot be explicit artifactsum inputs; declarations are excluded from artifactsum."
.into(),
));
}
let location = scope
.resolve_path(id, input)
.map_err(|failure| error(failure.0))?;
if location.artifact_id.as_str() != id.as_str() {
return Err(error(format!(
"{input} belongs to Artifact {}; dependencies come from children, mounts and references.",
location.artifact_id
)));
}
}
}
let mut resolved = Vec::new();
for eval in &config.evals {
let error = |field: &[&str], failure: ScopeError| {
let mut keys = vec!["evals", eval.declaration.id.as_ref()];
keys.extend_from_slice(field);
ConfigError::declaration(
config
.root
.join(config.artifacts[&eval.target].declaration_path()),
&keys,
format!("Eval {}: {failure}", eval.id),
)
};
let mut references = BTreeMap::new();
let mut deps = BTreeSet::new();
if let Profile::Dependency { depends_on } = eval.declaration.profile() {
for name in depends_on {
let source = reference_target(config, &eval.target, name)
.map_err(|failure| error(&["profile", "depends_on"], failure))?;
if source == &eval.target {
return Err(error(
&["profile", "depends_on"],
ScopeError("Dependency Evals cannot depend on their own Artifact.".into()),
));
}
if !deps.insert(source.clone()) {
return Err(error(
&["profile", "depends_on"],
ScopeError(
"Dependency profile depends_on must resolve to unique Artifacts."
.into(),
),
));
}
relations.push(Relation {
source: source.clone(),
target: eval.target.clone(),
kind: RelationKind::Dependency {
eval_id: eval.id.clone(),
name: name.into(),
},
});
}
}
let instruction = eval
.declaration
.payload()
.map_or("", |payload| payload.instruction.as_str());
for reference in instruction::references(instruction) {
let name = reference.name;
let source = reference_target(config, &eval.target, &name)
.map_err(|failure| error(&["payload", "instruction"], failure))?;
if instruction[reference.end..].starts_with('/') {
reference_path(config, source, "/")
.map_err(|failure| error(&["payload", "instruction"], failure))?;
}
if references.insert(name.clone(), source.clone()).is_some() {
continue;
}
if source != &eval.target {
deps.insert(source.clone());
relations.push(Relation {
source: source.clone(),
target: eval.target.clone(),
kind: RelationKind::Instruction {
eval_id: eval.id.clone(),
name,
},
});
}
}
if let Profile::Runtime { args, .. } = eval.declaration.profile() {
for (index, argument) in args.iter().enumerate() {
let Argument::Reference {
name: ref_name,
path,
..
} = argument
else {
continue;
};
let source = reference_target(config, &eval.target, ref_name)
.map_err(|failure| error(&["profile", "args"], failure))?;
reference_path(config, source, path)
.map_err(|failure| error(&["profile", "args"], failure))?;
if source != &eval.target {
deps.insert(source.clone());
relations.push(Relation {
source: source.clone(),
target: eval.target.clone(),
kind: RelationKind::Argument {
eval_id: eval.id.clone(),
index,
name: ref_name.clone(),
path: path.clone(),
},
});
}
}
}
resolved.push((references, deps.into_iter().collect()));
}
for (eval, (references, deps)) in config.evals.iter_mut().zip(resolved) {
eval.references = references;
eval.deps = deps;
}
config.relations = relations;
human::validate_references(config)?;
crate::graph::Graph::new(config).map_err(|error| ConfigError::new(&config.root, error))?;
Ok(())
}
#[cfg(test)]
mod tests;