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 Mount {
40 alias: crate::config::MountAlias,
41 },
42 Dependency {
43 #[serde(rename = "evalId")]
44 eval_id: crate::config::EvalId,
45 name: crate::config::ReferenceName,
46 },
47 Instruction {
48 #[serde(rename = "evalId")]
49 eval_id: crate::config::EvalId,
50 name: crate::config::ReferenceName,
51 },
52 #[serde(rename = "argv")]
53 Argument {
54 #[serde(rename = "evalId")]
55 eval_id: crate::config::EvalId,
56 index: usize,
57 name: crate::config::ReferenceName,
58 path: String,
59 },
60}
61
62#[derive(Debug, Clone)]
63pub struct Scope<'a> {
64 pub artifacts: BTreeMap<ArtifactName, &'a Artifact>,
65}
66
67impl Scope<'_> {
68 pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Scope {
70 artifactize_tools::scope::Scope {
71 artifacts: self
72 .artifacts
73 .iter()
74 .map(|(id, artifact)| (tool_id(id), artifact.tool_scope()))
75 .collect(),
76 }
77 }
78
79 pub fn resolve_path(
80 &self,
81 artifact_id: &ArtifactName,
82 path: &str,
83 ) -> Result<ScopedPath, ScopeError> {
84 self.tool_scope().resolve_path(&tool_id(artifact_id), path)
85 }
86
87 pub fn resolve_input(
88 &self,
89 root: &Path,
90 artifact_id: &ArtifactName,
91 path: &str,
92 ) -> Result<PathBuf, ScopeError> {
93 self.tool_scope()
94 .resolve_input(root, &tool_id(artifact_id), path)
95 }
96}
97
98pub(crate) fn tool_id(name: &ArtifactName) -> ArtifactId {
101 ArtifactId::new(name.as_str()).expect("configuration validates Artifact names")
102}
103
104pub(crate) fn validate_file_target(root: &Path, artifact: &Artifact) -> Result<(), ScopeError> {
106 if artifact.file_name().is_some() {
107 artifact
108 .path
109 .to_str()
110 .ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
111 let target = crate::platform::path_text(&artifact.path);
112 let file = open_scoped(root, &target).map_err(|error| {
113 ScopeError(format!(
114 "File Artifact {} target {} is unavailable: {error}",
115 artifact.name,
116 crate::platform::path_text(&artifact.path)
117 ))
118 })?;
119 if !file
120 .metadata()
121 .map_err(|error| ScopeError(error.to_string()))?
122 .is_file()
123 {
124 return Err(ScopeError(format!(
125 "File Artifact {} target {} must remain a regular file.",
126 artifact.name,
127 crate::platform::path_text(&artifact.path)
128 )));
129 }
130 }
131 Ok(())
132}
133
134fn reference_path(config: &RepoConfig, id: &ArtifactName, path: &str) -> Result<(), ScopeError> {
135 if config.artifacts[id].file_name().is_some() && !path.is_empty() {
136 return Err(ScopeError(format!(
137 "File Artifact {{{id}}} cannot have a /path suffix."
138 )));
139 }
140 Ok(())
141}
142
143pub fn artifact_scope<'a>(
145 config: &'a RepoConfig,
146 roots: &[&ArtifactName],
147) -> Result<Scope<'a>, ScopeError> {
148 let mut artifacts = BTreeMap::new();
149 let mut pending = roots.to_vec();
150 while let Some(id) = pending.pop() {
151 let (id, artifact) = config
152 .artifacts
153 .get_key_value(id)
154 .ok_or_else(|| ScopeError(format!("Unknown Artifact: {id}")))?;
155 if artifacts.insert(id.clone(), artifact).is_some() {
156 continue;
157 }
158 pending.extend(artifact.children.values());
159 pending.extend(artifact.mounts.values());
160 }
161 Ok(Scope { artifacts })
162}
163
164pub(crate) fn executable(
167 root: &Path,
168 scope: &Scope<'_>,
169 owner: &ArtifactName,
170 command: &str,
171) -> Result<std::ffi::OsString, String> {
172 let artifact = scope.artifacts[owner];
173 let cwd = scoped_path(root, artifact.folder()).map_err(|error| error.to_string())?;
174 let program = if let Some(relative) = crate::platform::program::folder_relative(command) {
175 let mounted = artifact
176 .mounts
177 .contains_key(relative.split('/').next().unwrap_or(""));
178 let physical = if artifact.file_name().is_some() && !mounted {
179 artifactize_tools::scope::logical_path(relative).map_err(|error| error.to_string())?;
180 cwd.join(relative)
181 } else {
182 let resolved = scope
183 .resolve_path(owner, relative)
184 .map_err(|error| error.to_string())?;
185 root.join(scope.artifacts[resolved.artifact_id.as_str()].folder())
186 .join(resolved.path)
187 };
188 crate::platform::program::reject_link_ancestors(&physical)?;
190 let mut failure = None;
191 crate::platform::program::candidates(Path::new(relative))
192 .into_iter()
193 .find_map(|candidate| {
194 let candidate = if candidate.as_path() != Path::new(relative) {
196 executable_spelling(root, scope, owner, &cwd, &candidate, mounted)?
197 } else {
198 candidate
199 };
200 let resolved = if artifact.file_name().is_some() && !mounted {
201 scoped_path(&cwd, &candidate)
202 } else {
203 scope.resolve_input(
204 root,
205 owner,
206 &artifactize_tools::scope::logical_from_native(&candidate)?,
207 )
208 };
209 match resolved {
210 Ok(program) if program.is_file() => Some(program),
211 Ok(_) => None,
212 Err(error) => {
213 failure.get_or_insert_with(|| error.to_string());
214 None
215 }
216 }
217 })
218 .ok_or_else(|| {
219 failure.unwrap_or_else(|| "Executable path is unavailable or outside scope.".into())
220 })?
221 } else {
222 PathBuf::from(command)
223 };
224 Ok(program.into_os_string())
225}
226
227fn executable_spelling(
230 root: &Path,
231 scope: &Scope<'_>,
232 owner: &ArtifactName,
233 cwd: &Path,
234 candidate: &Path,
235 mounted: bool,
236) -> Option<PathBuf> {
237 let parent = candidate.parent().unwrap_or(Path::new(""));
238 let directory = if scope.artifacts[owner].file_name().is_some() && !mounted {
239 scoped_path(cwd, parent).ok()?
240 } else {
241 scope.resolve_input(root, owner, parent.to_str()?).ok()?
242 };
243 crate::platform::program::suffix_spelling(&directory, candidate)
244}
245
246pub fn eval_scope<'a>(config: &'a RepoConfig, eval: &Eval) -> Result<Scope<'a>, ScopeError> {
247 let roots: Vec<_> = std::iter::once(&eval.target)
248 .chain(eval.deps.iter())
249 .collect();
250 artifact_scope(config, &roots)
251}
252
253pub fn argv_scope<'a>(
255 config: &'a RepoConfig,
256 owner: &ArtifactName,
257 args: &[Argument],
258) -> Result<Scope<'a>, ScopeError> {
259 let mut roots = vec![owner];
260 for argument in args {
261 if let Argument::Reference { name, .. } = argument {
262 roots.push(reference_target(config, owner, name)?);
263 }
264 }
265 artifact_scope(config, &roots)
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum InstructionPart {
270 Text(String),
271 Artifact(ArtifactName),
272}
273
274pub fn parse_artifact_instruction(
277 source: &str,
278 scope: &Scope<'_>,
279 references: &BTreeMap<crate::config::ReferenceName, ArtifactName>,
280) -> Vec<InstructionPart> {
281 let mut parts = Vec::new();
282 let mut start = 0;
283 for reference in instruction::references(source) {
284 let artifact = match references.get(&reference.name) {
285 Some(id) => scope.artifacts.get(id),
286 None => scope.artifacts.get(reference.name.as_str()),
287 };
288 let Some(artifact) = artifact else {
289 continue;
290 };
291 if start < reference.start {
292 parts.push(InstructionPart::Text(source[start..reference.start].into()));
293 }
294 parts.push(InstructionPart::Artifact(artifact.name.clone()));
295 start = reference.end;
296 }
297 if start < source.len() {
298 parts.push(InstructionPart::Text(source[start..].into()));
299 }
300 parts
301}
302
303fn reference_target<'a>(
304 config: &'a RepoConfig,
305 owner: &ArtifactName,
306 name: &crate::config::ReferenceName,
307) -> Result<&'a ArtifactName, ScopeError> {
308 let artifact = config
309 .artifacts
310 .get(owner)
311 .ok_or_else(|| ScopeError(format!("Unknown Artifact: {owner}")))?;
312 let target = match artifact.mounts.get(name.as_str()) {
313 Some(target) => config.artifacts.get_key_value(target),
314 None => config.artifacts.get_key_value(name.as_str()),
315 };
316 target.map(|(id, _)| id).ok_or_else(|| {
317 ScopeError(format!(
318 "Unknown Artifact reference {{{name}}}. Escape literal braces with a backslash."
319 ))
320 })
321}
322
323pub fn resolve_argv(
327 config: &RepoConfig,
328 scope: &Scope<'_>,
329 owner: &ArtifactName,
330 args: &[Argument],
331) -> Result<Vec<String>, ScopeError> {
332 args.iter()
333 .map(|argument| {
334 let Argument::Reference { prefix, name, path } = argument else {
335 return Ok(argument.to_string());
336 };
337 let id = reference_target(config, owner, name)?;
338 reference_path(config, id, path)?;
339 let resolved = scope.resolve_input(&config.root, id, path)?;
340 resolved
341 .to_str()
342 .ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
343 let resolved = crate::platform::path_text(&resolved);
344 Ok(format!("{prefix}{resolved}"))
345 })
346 .collect()
347}
348
349pub(crate) fn resolve_config(config: &mut RepoConfig) -> Result<(), ConfigError> {
351 let mut relations = Vec::new();
352 for (id, artifact) in &config.artifacts {
353 let error = |keys: &[&str], message: String| {
354 ConfigError::declaration(config.root.join(artifact.declaration_path()), keys, message)
355 };
356 for (name, tool) in &artifact.views.agent_tools {
357 if let crate::config::AgentTool::Builtin(tool) = tool
358 && let Some(args) = &tool.args
359 {
360 let scope = artifact_scope(config, &[id]).map_err(|failure| {
361 error(&["views", "agent_tools", name, "args"], failure.to_string())
362 })?;
363 let owner = ArtifactId::new(id.as_str()).map_err(|failure| {
364 error(&["views", "agent_tools", name, "args"], failure.to_string())
365 })?;
366 artifactize_tools::builtin::validate_target(
367 tool.builtin,
368 args,
369 &config.root,
370 &scope.tool_scope(),
371 &owner,
372 )
373 .map_err(|failure| error(&["views", "agent_tools", name, "args"], failure))?;
374 }
375 if let crate::config::AgentTool::Command(tool) = tool
376 && tool.protocol == crate::config::ToolProtocol::Json
377 {
378 for argument in &tool.args {
379 if let Argument::Reference {
380 name: ref_name,
381 path,
382 ..
383 } = argument
384 {
385 let target = reference_target(config, id, ref_name).map_err(|failure| {
386 error(
387 &["views", "agent_tools", name, "args"],
388 format!("Agent tool {name}: {failure}"),
389 )
390 })?;
391 reference_path(config, target, path).map_err(|failure| {
392 error(
393 &["views", "agent_tools", name, "args"],
394 format!("Agent tool {name}: {failure}"),
395 )
396 })?;
397 }
398 }
399 }
400 }
401 if let Some(Fingerprint::Script { args, .. }) = &artifact.fingerprint {
402 for argument in args {
403 if let Argument::Reference {
404 name: ref_name,
405 path,
406 ..
407 } = argument
408 {
409 let target = reference_target(config, id, ref_name).map_err(|failure| {
410 error(
411 &["fingerprint", "script", "args"],
412 format!("fingerprint.script: {failure}"),
413 )
414 })?;
415 reference_path(config, target, path).map_err(|failure| {
416 error(
417 &["fingerprint", "script", "args"],
418 format!("fingerprint.script: {failure}"),
419 )
420 })?;
421 }
422 }
423 }
424 for (path, source) in &artifact.children {
425 relations.push(Relation {
426 source: source.clone(),
427 target: id.clone(),
428 kind: RelationKind::Child { path: path.clone() },
429 });
430 }
431 for (alias, source) in &artifact.mounts {
432 if !config.artifacts.contains_key(source) {
433 return Err(error(
434 &["mounts", alias],
435 format!("Unknown mount target {source} in {id}."),
436 ));
437 }
438 if config.artifacts.contains_key(alias.as_str()) && alias.as_str() != source.as_str() {
439 return Err(error(
440 &["mounts", alias],
441 format!("Ambiguous mount alias {alias} in {id}."),
442 ));
443 }
444 if artifact.file_name().is_some() {
445 if artifact.file_name() == Some(alias.as_str()) {
446 return Err(error(
447 &["mounts", alias],
448 format!("Mount {id}/{alias} conflicts with the target file."),
449 ));
450 }
451 } else {
452 match crate::platform::entry_exists(
453 &config.root.join(artifact.folder()).join(alias.as_str()),
454 ) {
455 Ok(true) => {
456 return Err(error(
457 &["mounts", alias],
458 format!("Mount {id}/{alias} conflicts with a physical entry."),
459 ));
460 }
461 Ok(false) => {}
462 Err(failure) => return Err(error(&["mounts", alias], failure.to_string())),
463 }
464 }
465 relations.push(Relation {
466 source: source.clone(),
467 target: id.clone(),
468 kind: RelationKind::Mount {
469 alias: alias.clone(),
470 },
471 });
472 }
473 }
474 for (id, artifact) in &config.artifacts {
475 let Some(Fingerprint::Artifactsum { files: inputs, .. }) = &artifact.fingerprint else {
476 continue;
477 };
478 let error = |message: String| {
479 ConfigError::declaration(
480 config.root.join(artifact.declaration_path()),
481 &["fingerprint", "files"],
482 format!("fingerprint.files: {message}"),
483 )
484 };
485 let scope = artifact_scope(config, &[id]).map_err(|failure| error(failure.0))?;
486 for input in inputs.iter().filter(|input| *input != ".") {
487 if input.ends_with(".artf")
488 && !crate::platform::path_kind(&config.root.join(artifact.folder()).join(input))
489 .is_ok_and(|kind| kind == crate::platform::FileKind::Directory)
490 {
491 return Err(error(
492 "Artifact declarations (*.artf) cannot be explicit artifactsum inputs; declarations are excluded from artifactsum."
493 .into(),
494 ));
495 }
496 let location = scope
497 .resolve_path(id, input)
498 .map_err(|failure| error(failure.0))?;
499 if location.artifact_id.as_str() != id.as_str() {
500 return Err(error(format!(
501 "{input} belongs to Artifact {}; dependencies come from children, mounts and references.",
502 location.artifact_id
503 )));
504 }
505 }
506 }
507 let mut resolved = Vec::new();
508 for eval in &config.evals {
509 let error = |field: &[&str], failure: ScopeError| {
510 let mut keys = vec!["evals", eval.declaration.id.as_ref()];
511 keys.extend_from_slice(field);
512 ConfigError::declaration(
513 config
514 .root
515 .join(config.artifacts[&eval.target].declaration_path()),
516 &keys,
517 format!("Eval {}: {failure}", eval.id),
518 )
519 };
520 let mut references = BTreeMap::new();
521 let mut deps = BTreeSet::new();
522 if let Profile::Dependency { depends_on } = eval.declaration.profile() {
523 for name in depends_on {
524 let source = reference_target(
525 config,
526 &eval.target,
527 &crate::config::ReferenceName::from(name),
528 )
529 .map_err(|failure| error(&["profile", "depends_on"], failure))?;
530 if source == &eval.target {
531 return Err(error(
532 &["profile", "depends_on"],
533 ScopeError("Dependency Evals cannot depend on their own Artifact.".into()),
534 ));
535 }
536 if !deps.insert(source.clone()) {
537 return Err(error(
538 &["profile", "depends_on"],
539 ScopeError(
540 "Dependency profile depends_on must resolve to unique Artifacts."
541 .into(),
542 ),
543 ));
544 }
545 relations.push(Relation {
546 source: source.clone(),
547 target: eval.target.clone(),
548 kind: RelationKind::Dependency {
549 eval_id: eval.id.clone(),
550 name: name.into(),
551 },
552 });
553 }
554 }
555 let instruction = eval
556 .declaration
557 .payload()
558 .map_or("", |payload| payload.instruction.as_str());
559 for reference in instruction::references(instruction) {
560 let name = reference.name;
561 let source = reference_target(config, &eval.target, &name)
562 .map_err(|failure| error(&["payload", "instruction"], failure))?;
563 if instruction[reference.end..].starts_with('/') {
564 reference_path(config, source, "/")
565 .map_err(|failure| error(&["payload", "instruction"], failure))?;
566 }
567 if references.insert(name.clone(), source.clone()).is_some() {
568 continue;
569 }
570 if source != &eval.target {
571 deps.insert(source.clone());
572 relations.push(Relation {
573 source: source.clone(),
574 target: eval.target.clone(),
575 kind: RelationKind::Instruction {
576 eval_id: eval.id.clone(),
577 name,
578 },
579 });
580 }
581 }
582 if let Profile::Runtime { args, .. } = eval.declaration.profile() {
583 for (index, argument) in args.iter().enumerate() {
584 let Argument::Reference {
585 name: ref_name,
586 path,
587 ..
588 } = argument
589 else {
590 continue;
591 };
592 let source = reference_target(config, &eval.target, ref_name)
593 .map_err(|failure| error(&["profile", "args"], failure))?;
594 reference_path(config, source, path)
595 .map_err(|failure| error(&["profile", "args"], failure))?;
596 if source != &eval.target {
597 deps.insert(source.clone());
598 relations.push(Relation {
599 source: source.clone(),
600 target: eval.target.clone(),
601 kind: RelationKind::Argument {
602 eval_id: eval.id.clone(),
603 index,
604 name: ref_name.clone(),
605 path: path.to_string(),
606 },
607 });
608 }
609 }
610 }
611 resolved.push((references, deps.into_iter().collect()));
612 }
613 for (eval, (references, deps)) in config.evals.iter_mut().zip(resolved) {
614 eval.references = references;
615 eval.deps = deps;
616 }
617 config.relations = relations;
618 human::validate_references(config)?;
619 crate::graph::Graph::new(config).map_err(|error| ConfigError::new(&config.root, error))?;
620 Ok(())
621}
622
623#[cfg(test)]
624mod tests;