1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use serde::Serialize;
7
8use crate::config::{Artifact, ArtifactName, ConfigError, Eval, Fingerprint, Profile, RepoConfig};
9
10mod argument;
11pub use argument::{Argument, LogicalSuffix, LogicalTarget};
12
13mod human;
14mod instruction;
15pub(crate) use human::{
16 builtin_args, human_arguments, resolve_human_argv, validate_human_argument,
17};
18pub use instruction::instruction_references;
19
20pub use artifactize_tools::scope::{ArtifactId, ScopeError, ScopedPath, scoped_path};
21pub(crate) use artifactize_tools::scope::{logical_path, open_child, open_scoped};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24pub struct Relation {
25 pub source: ArtifactName,
27 pub target: ArtifactName,
29 #[serde(flatten)]
30 pub kind: RelationKind,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34#[serde(tag = "kind", rename_all = "lowercase")]
35pub enum RelationKind {
36 Child {
37 path: crate::config::ChildPrefix,
38 },
39 Instruction {
40 #[serde(rename = "evalId")]
41 eval_id: crate::config::EvalId,
42 name: crate::config::ReferenceName,
43 },
44 #[serde(rename = "argv")]
45 Argument {
46 #[serde(rename = "evalId")]
47 eval_id: crate::config::EvalId,
48 index: usize,
49 name: crate::config::ReferenceName,
50 path: LogicalTarget,
51 },
52}
53
54#[derive(Debug, Clone)]
55pub struct Scope<'a> {
56 pub artifacts: BTreeMap<ArtifactName, &'a Artifact>,
57}
58
59impl Scope<'_> {
60 pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Scope {
62 artifactize_tools::scope::Scope {
63 artifacts: self
64 .artifacts
65 .iter()
66 .map(|(id, artifact)| (tool_id(id), artifact.tool_scope()))
67 .collect(),
68 }
69 }
70
71 pub fn resolve_path(
72 &self,
73 artifact_id: &ArtifactName,
74 path: &str,
75 ) -> Result<ScopedPath, ScopeError> {
76 self.tool_scope().resolve_path(&tool_id(artifact_id), path)
77 }
78
79 pub fn resolve_input(
80 &self,
81 root: &Path,
82 artifact_id: &ArtifactName,
83 path: &str,
84 ) -> Result<PathBuf, ScopeError> {
85 self.tool_scope()
86 .resolve_input(root, &tool_id(artifact_id), path)
87 }
88}
89
90pub(crate) fn tool_id(name: &ArtifactName) -> ArtifactId {
93 ArtifactId::new(name.as_str()).expect("configuration validates Artifact names")
94}
95
96pub(crate) fn validate_file_target(root: &Path, artifact: &Artifact) -> Result<(), ScopeError> {
98 if artifact.file_name().is_some() {
99 artifact
100 .path
101 .to_str()
102 .ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
103 let target = crate::platform::path_text(&artifact.path);
104 let file = open_scoped(root, &target).map_err(|error| {
105 ScopeError(format!(
106 "File Artifact {} target {} is unavailable: {error}",
107 artifact.name,
108 crate::platform::path_text(&artifact.path)
109 ))
110 })?;
111 if !file
112 .metadata()
113 .map_err(|error| ScopeError(error.to_string()))?
114 .is_file()
115 {
116 return Err(ScopeError(format!(
117 "File Artifact {} target {} must remain a regular file.",
118 artifact.name,
119 crate::platform::path_text(&artifact.path)
120 )));
121 }
122 }
123 Ok(())
124}
125
126fn reference_path(config: &RepoConfig, id: &ArtifactName, path: &str) -> Result<(), ScopeError> {
127 if config.artifacts[id].file_name().is_some() && !path.is_empty() {
128 return Err(ScopeError(format!(
129 "File Artifact {{{id}}} cannot have a /path suffix."
130 )));
131 }
132 Ok(())
133}
134
135pub fn artifact_scope<'a>(
137 config: &'a RepoConfig,
138 roots: &[&ArtifactName],
139) -> Result<Scope<'a>, ScopeError> {
140 let mut artifacts = BTreeMap::new();
141 let mut pending = roots.to_vec();
142 while let Some(id) = pending.pop() {
143 let (id, artifact) = config
144 .artifacts
145 .get_key_value(id)
146 .ok_or_else(|| ScopeError(format!("Unknown Artifact: {id}")))?;
147 if artifacts.insert(id.clone(), artifact).is_some() {
148 continue;
149 }
150 pending.extend(artifact.children.values());
151 }
152 Ok(Scope { artifacts })
153}
154
155pub(crate) fn executable(
158 root: &Path,
159 scope: &Scope<'_>,
160 owner: &ArtifactName,
161 command: &str,
162) -> Result<std::ffi::OsString, String> {
163 let artifact = scope.artifacts[owner];
164 let cwd = scoped_path(root, artifact.folder()).map_err(|error| error.to_string())?;
165 let program = if let Some(relative) = crate::platform::program::folder_relative(command) {
166 let physical = if artifact.file_name().is_some() {
167 artifactize_tools::scope::logical_path(relative).map_err(|error| error.to_string())?;
168 cwd.join(relative)
169 } else {
170 let resolved = scope
171 .resolve_path(owner, relative)
172 .map_err(|error| error.to_string())?;
173 root.join(scope.artifacts[resolved.artifact_id.as_str()].folder())
174 .join(resolved.path)
175 };
176 crate::platform::program::reject_link_ancestors(&physical)?;
178 let mut failure = None;
179 crate::platform::program::candidates(Path::new(relative))
180 .into_iter()
181 .find_map(|candidate| {
182 let candidate = if candidate.as_path() != Path::new(relative) {
184 executable_spelling(root, scope, owner, &cwd, &candidate)?
185 } else {
186 candidate
187 };
188 let resolved = if artifact.file_name().is_some() {
189 scoped_path(&cwd, &candidate)
190 } else {
191 scope.resolve_input(
192 root,
193 owner,
194 &artifactize_tools::scope::logical_from_native(&candidate)?,
195 )
196 };
197 match resolved {
198 Ok(program) if program.is_file() => Some(program),
199 Ok(_) => None,
200 Err(error) => {
201 failure.get_or_insert_with(|| error.to_string());
202 None
203 }
204 }
205 })
206 .ok_or_else(|| {
207 failure.unwrap_or_else(|| "Executable path is unavailable or outside scope.".into())
208 })?
209 } else {
210 PathBuf::from(command)
211 };
212 Ok(program.into_os_string())
213}
214
215fn executable_spelling(
218 root: &Path,
219 scope: &Scope<'_>,
220 owner: &ArtifactName,
221 cwd: &Path,
222 candidate: &Path,
223) -> Option<PathBuf> {
224 let parent = candidate.parent().unwrap_or(Path::new(""));
225 let directory = if scope.artifacts[owner].file_name().is_some() {
226 scoped_path(cwd, parent).ok()?
227 } else {
228 scope.resolve_input(root, owner, parent.to_str()?).ok()?
229 };
230 crate::platform::program::suffix_spelling(&directory, candidate)
231}
232
233pub fn eval_scope<'a>(config: &'a RepoConfig, eval: &Eval) -> Result<Scope<'a>, ScopeError> {
234 let roots: Vec<_> = std::iter::once(&eval.target)
235 .chain(eval.deps.iter())
236 .collect();
237 artifact_scope(config, &roots)
238}
239
240pub fn argv_scope<'a>(
242 config: &'a RepoConfig,
243 owner: &ArtifactName,
244 args: &[Argument],
245) -> Result<Scope<'a>, ScopeError> {
246 let mut roots = vec![owner];
247 for argument in args {
248 if let Argument::Reference { name, .. } = argument {
249 roots.push(reference_target(config, owner, name)?);
250 }
251 }
252 artifact_scope(config, &roots)
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum InstructionPart {
257 Text(String),
258 Artifact(ArtifactName),
259}
260
261pub fn parse_artifact_instruction(
264 source: &str,
265 scope: &Scope<'_>,
266 references: &BTreeMap<crate::config::ReferenceName, ArtifactName>,
267) -> Vec<InstructionPart> {
268 let mut parts = Vec::new();
269 let mut start = 0;
270 for reference in instruction::references(source) {
271 let artifact = match references.get(&reference.name) {
272 Some(id) => scope.artifacts.get(id),
273 None => scope.artifacts.get(reference.name.as_str()),
274 };
275 let Some(artifact) = artifact else {
276 continue;
277 };
278 if start < reference.start {
279 parts.push(InstructionPart::Text(source[start..reference.start].into()));
280 }
281 parts.push(InstructionPart::Artifact(artifact.name.clone()));
282 start = reference.end;
283 }
284 if start < source.len() {
285 parts.push(InstructionPart::Text(source[start..].into()));
286 }
287 parts
288}
289
290fn reference_target<'a>(
291 config: &'a RepoConfig,
292 owner: &ArtifactName,
293 name: &crate::config::ReferenceName,
294) -> Result<&'a ArtifactName, ScopeError> {
295 if !config.artifacts.contains_key(owner) {
296 return Err(ScopeError(format!("Unknown Artifact: {owner}")));
297 }
298 let target = config.artifacts.get_key_value(name.as_str());
299 target.map(|(id, _)| id).ok_or_else(|| {
300 ScopeError(format!(
301 "Unknown Artifact reference {{{name}}}. Escape literal braces with a backslash."
302 ))
303 })
304}
305
306pub fn resolve_argv(
310 config: &RepoConfig,
311 scope: &Scope<'_>,
312 owner: &ArtifactName,
313 args: &[Argument],
314) -> Result<Vec<String>, ScopeError> {
315 args.iter()
316 .map(|argument| {
317 let Argument::Reference { prefix, name, path } = argument else {
318 return Ok(argument.to_string());
319 };
320 let id = reference_target(config, owner, name)?;
321 reference_path(config, id, path)?;
322 let resolved = scope.resolve_input(&config.root, id, path)?;
323 resolved
324 .to_str()
325 .ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
326 let resolved = crate::platform::path_text(&resolved);
327 Ok(format!("{prefix}{resolved}"))
328 })
329 .collect()
330}
331
332pub(crate) fn resolve_config(config: &mut RepoConfig) -> Result<(), ConfigError> {
334 let mut relations = Vec::new();
335 for (id, artifact) in &config.artifacts {
336 let error = |keys: &[&str], message: String| {
337 ConfigError::declaration(config.root.join(artifact.declaration_path()), keys, message)
338 };
339 for (name, tool) in &artifact.views.agent_tools {
340 if let crate::config::AgentTool::Builtin(tool) = tool
341 && let Some(args) = &tool.args
342 {
343 let scope = artifact_scope(config, &[id]).map_err(|failure| {
344 error(&["views", "agent_tools", name, "args"], failure.to_string())
345 })?;
346 let owner = ArtifactId::new(id.as_str()).map_err(|failure| {
347 error(&["views", "agent_tools", name, "args"], failure.to_string())
348 })?;
349 artifactize_tools::builtin::validate_target(
350 tool.builtin,
351 args,
352 &config.root,
353 &scope.tool_scope(),
354 &owner,
355 )
356 .map_err(|failure| error(&["views", "agent_tools", name, "args"], failure))?;
357 }
358 if let crate::config::AgentTool::Command(tool) = tool
359 && tool.protocol == crate::config::ToolProtocol::Json
360 {
361 for argument in &tool.args {
362 if let Argument::Reference {
363 name: ref_name,
364 path,
365 ..
366 } = argument
367 {
368 let target = reference_target(config, id, ref_name).map_err(|failure| {
369 error(
370 &["views", "agent_tools", name, "args"],
371 format!("Agent tool {name}: {failure}"),
372 )
373 })?;
374 reference_path(config, target, path).map_err(|failure| {
375 error(
376 &["views", "agent_tools", name, "args"],
377 format!("Agent tool {name}: {failure}"),
378 )
379 })?;
380 }
381 }
382 }
383 }
384 if let Some(Fingerprint::Script { args, .. }) = &artifact.fingerprint {
385 for argument in args {
386 if let Argument::Reference {
387 name: ref_name,
388 path,
389 ..
390 } = argument
391 {
392 let target = reference_target(config, id, ref_name).map_err(|failure| {
393 error(
394 &["fingerprint", "script", "args"],
395 format!("fingerprint.script: {failure}"),
396 )
397 })?;
398 reference_path(config, target, path).map_err(|failure| {
399 error(
400 &["fingerprint", "script", "args"],
401 format!("fingerprint.script: {failure}"),
402 )
403 })?;
404 }
405 }
406 }
407 for (path, source) in &artifact.children {
408 relations.push(Relation {
409 source: source.clone(),
410 target: id.clone(),
411 kind: RelationKind::Child { path: path.clone() },
412 });
413 }
414 }
415 for (id, artifact) in &config.artifacts {
416 let Some(Fingerprint::Artifactsum { files: inputs, .. }) = &artifact.fingerprint else {
417 continue;
418 };
419 let error = |message: String| {
420 ConfigError::declaration(
421 config.root.join(artifact.declaration_path()),
422 &["fingerprint", "files"],
423 format!("fingerprint.files: {message}"),
424 )
425 };
426 let scope = artifact_scope(config, &[id]).map_err(|failure| error(failure.0))?;
427 for input in inputs.iter().filter(|input| *input != ".") {
428 if input.ends_with(".artf")
429 && !crate::platform::path_kind(&config.root.join(artifact.folder()).join(input))
430 .is_ok_and(|kind| kind == crate::platform::FileKind::Directory)
431 {
432 return Err(error(
433 "Artifact declarations (*.artf) cannot be explicit artifactsum inputs; declarations are excluded from artifactsum."
434 .into(),
435 ));
436 }
437 let location = scope
438 .resolve_path(id, input)
439 .map_err(|failure| error(failure.0))?;
440 if location.artifact_id.as_str() != id.as_str() {
441 return Err(error(format!(
442 "{input} belongs to Artifact {}; dependencies come from children and references.",
443 location.artifact_id
444 )));
445 }
446 }
447 }
448 let mut resolved = Vec::new();
449 for eval in &config.evals {
450 let error = |field: &[&str], failure: ScopeError| {
451 let mut keys = vec!["evals", eval.declaration.id.as_ref()];
452 keys.extend_from_slice(field);
453 ConfigError::declaration(
454 config
455 .root
456 .join(config.artifacts[&eval.target].declaration_path()),
457 &keys,
458 format!("Eval {}: {failure}", eval.id),
459 )
460 };
461 let mut references = BTreeMap::new();
462 let mut deps = BTreeSet::new();
463 let instruction = eval
464 .declaration
465 .payload()
466 .map_or("", |payload| payload.instruction.as_str());
467 for reference in instruction::references(instruction) {
468 let name = reference.name;
469 let source = reference_target(config, &eval.target, &name)
470 .map_err(|failure| error(&["payload", "instruction"], failure))?;
471 if instruction[reference.end..].starts_with('/') {
472 reference_path(config, source, "/")
473 .map_err(|failure| error(&["payload", "instruction"], failure))?;
474 }
475 if references.insert(name.clone(), source.clone()).is_some() {
476 continue;
477 }
478 if source != &eval.target {
479 deps.insert(source.clone());
480 relations.push(Relation {
481 source: source.clone(),
482 target: eval.target.clone(),
483 kind: RelationKind::Instruction {
484 eval_id: eval.id.clone(),
485 name,
486 },
487 });
488 }
489 }
490 if let Profile::Runtime { args, .. } = eval.declaration.profile() {
491 for (index, argument) in args.iter().enumerate() {
492 let Argument::Reference {
493 name: ref_name,
494 path,
495 ..
496 } = argument
497 else {
498 continue;
499 };
500 let source = reference_target(config, &eval.target, ref_name)
501 .map_err(|failure| error(&["profile", "args"], failure))?;
502 reference_path(config, source, path)
503 .map_err(|failure| error(&["profile", "args"], failure))?;
504 if source != &eval.target {
505 deps.insert(source.clone());
506 relations.push(Relation {
507 source: source.clone(),
508 target: eval.target.clone(),
509 kind: RelationKind::Argument {
510 eval_id: eval.id.clone(),
511 index,
512 name: ref_name.clone(),
513 path: path.clone(),
514 },
515 });
516 }
517 }
518 }
519 resolved.push((references, deps.into_iter().collect()));
520 }
521 for (eval, (references, deps)) in config.evals.iter_mut().zip(resolved) {
522 eval.references = references;
523 eval.deps = deps;
524 }
525 config.relations = relations;
526 human::validate_references(config)?;
527 crate::graph::Graph::new(config).map_err(|error| ConfigError::new(&config.root, error))?;
528 Ok(())
529}
530
531#[cfg(test)]
532mod tests;