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 Part,
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
135fn nested_path(
138 everything: &artifactize_tools::scope::Scope,
139 source: &ArtifactName,
140 path: &str,
141) -> Result<(), ScopeError> {
142 let resolved = everything.resolve_path(&tool_id(source), path)?;
143 if resolved.artifact_id.as_str() != source.as_str() {
144 return Err(ScopeError(format!(
145 "{{{source}}}/{path} is in Artifact {owner}; reference {{{owner}}}/{rest} instead.",
146 owner = resolved.artifact_id,
147 rest = resolved.path,
148 )));
149 }
150 Ok(())
151}
152
153pub fn artifact_scope<'a>(
157 config: &'a RepoConfig,
158 roots: &[&ArtifactName],
159) -> Result<Scope<'a>, ScopeError> {
160 let artifacts = roots
161 .iter()
162 .map(|&id| {
163 config
164 .artifacts
165 .get_key_value(id)
166 .map(|(id, artifact)| (id.clone(), artifact))
167 .ok_or_else(|| ScopeError(format!("Unknown Artifact: {id}")))
168 })
169 .collect::<Result<_, _>>()?;
170 Ok(Scope { artifacts })
171}
172
173pub(crate) fn executable(
176 root: &Path,
177 scope: &Scope<'_>,
178 owner: &ArtifactName,
179 command: &str,
180) -> Result<std::ffi::OsString, String> {
181 let artifact = scope.artifacts[owner];
182 let cwd = scoped_path(root, artifact.folder()).map_err(|error| error.to_string())?;
183 let program = if let Some(relative) = crate::platform::program::folder_relative(command) {
184 let physical = if artifact.file_name().is_some() {
185 artifactize_tools::scope::logical_path(relative).map_err(|error| error.to_string())?;
186 cwd.join(relative)
187 } else {
188 let resolved = scope
189 .resolve_path(owner, relative)
190 .map_err(|error| error.to_string())?;
191 root.join(scope.artifacts[resolved.artifact_id.as_str()].folder())
192 .join(resolved.path)
193 };
194 crate::platform::program::reject_link_ancestors(&physical)?;
196 let mut failure = None;
197 crate::platform::program::candidates(Path::new(relative))
198 .into_iter()
199 .find_map(|candidate| {
200 let candidate = if candidate.as_path() != Path::new(relative) {
202 executable_spelling(root, scope, owner, &cwd, &candidate)?
203 } else {
204 candidate
205 };
206 let resolved = if artifact.file_name().is_some() {
207 scoped_path(&cwd, &candidate)
208 } else {
209 scope.resolve_input(
210 root,
211 owner,
212 &artifactize_tools::scope::logical_from_native(&candidate)?,
213 )
214 };
215 match resolved {
216 Ok(program) if program.is_file() => Some(program),
217 Ok(_) => None,
218 Err(error) => {
219 failure.get_or_insert_with(|| error.to_string());
220 None
221 }
222 }
223 })
224 .ok_or_else(|| {
225 failure.unwrap_or_else(|| "Executable path is unavailable or outside scope.".into())
226 })?
227 } else {
228 PathBuf::from(command)
229 };
230 Ok(program.into_os_string())
231}
232
233fn executable_spelling(
236 root: &Path,
237 scope: &Scope<'_>,
238 owner: &ArtifactName,
239 cwd: &Path,
240 candidate: &Path,
241) -> Option<PathBuf> {
242 let parent = candidate.parent().unwrap_or(Path::new(""));
243 let directory = if scope.artifacts[owner].file_name().is_some() {
244 scoped_path(cwd, parent).ok()?
245 } else {
246 scope.resolve_input(root, owner, parent.to_str()?).ok()?
247 };
248 crate::platform::program::suffix_spelling(&directory, candidate)
249}
250
251pub fn eval_scope<'a>(config: &'a RepoConfig, eval: &Eval) -> Result<Scope<'a>, ScopeError> {
252 let roots: Vec<_> = std::iter::once(&eval.target)
253 .chain(eval.deps.iter())
254 .collect();
255 artifact_scope(config, &roots)
256}
257
258pub fn argv_scope<'a>(
260 config: &'a RepoConfig,
261 owner: &ArtifactName,
262 args: &[Argument],
263) -> Result<Scope<'a>, ScopeError> {
264 let mut roots = vec![owner];
265 for argument in args {
266 if let Argument::Reference { name, .. } = argument {
267 roots.push(reference_target(config, owner, name)?);
268 }
269 }
270 artifact_scope(config, &roots)
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub enum InstructionPart {
275 Text(String),
276 Artifact(ArtifactName),
277}
278
279pub fn parse_artifact_instruction(
282 source: &str,
283 scope: &Scope<'_>,
284 references: &BTreeMap<crate::config::ReferenceName, ArtifactName>,
285) -> Vec<InstructionPart> {
286 let mut parts = Vec::new();
287 let mut start = 0;
288 for reference in instruction::references(source) {
289 let artifact = match references.get(&reference.name) {
290 Some(id) => scope.artifacts.get(id),
291 None => scope.artifacts.get(reference.name.as_str()),
292 };
293 let Some(artifact) = artifact else {
294 continue;
295 };
296 if start < reference.start {
297 parts.push(InstructionPart::Text(source[start..reference.start].into()));
298 }
299 parts.push(InstructionPart::Artifact(artifact.name.clone()));
300 start = reference.end;
301 }
302 if start < source.len() {
303 parts.push(InstructionPart::Text(source[start..].into()));
304 }
305 parts
306}
307
308fn reference_target<'a>(
309 config: &'a RepoConfig,
310 owner: &ArtifactName,
311 name: &crate::config::ReferenceName,
312) -> Result<&'a ArtifactName, ScopeError> {
313 if !config.artifacts.contains_key(owner) {
314 return Err(ScopeError(format!("Unknown Artifact: {owner}")));
315 }
316 let target = config.artifacts.get_key_value(name.as_str());
317 target.map(|(id, _)| id).ok_or_else(|| {
318 ScopeError(format!(
319 "Unknown Artifact reference {{{name}}}. Escape literal braces with a backslash."
320 ))
321 })
322}
323
324pub fn resolve_argv(
328 config: &RepoConfig,
329 scope: &Scope<'_>,
330 owner: &ArtifactName,
331 args: &[Argument],
332) -> Result<Vec<String>, ScopeError> {
333 args.iter()
334 .map(|argument| {
335 let Argument::Reference { prefix, name, path } = argument else {
336 return Ok(argument.to_string());
337 };
338 let id = reference_target(config, owner, name)?;
339 reference_path(config, id, path)?;
340 let resolved = scope.resolve_input(&config.root, id, path)?;
341 resolved
342 .to_str()
343 .ok_or_else(|| ScopeError("Artifact paths must be UTF-8.".into()))?;
344 let resolved = crate::platform::path_text(&resolved);
345 Ok(format!("{prefix}{resolved}"))
346 })
347 .collect()
348}
349
350pub(crate) fn validate_selections(config: &RepoConfig) -> Result<(), ConfigError> {
352 for (id, artifact) in &config.artifacts {
353 let scope = artifact_scope(config, &[id]).expect("a configured Artifact");
354 for selected in &artifact.select {
355 scope
356 .resolve_input(&config.root, id, selected)
357 .map_err(|failure| {
358 ConfigError::declaration(
359 config.root.join(artifact.declaration_path()),
360 &["select"],
361 format!("select: {selected}: {failure}"),
362 )
363 })?;
364 }
365 }
366 Ok(())
367}
368
369pub(crate) fn resolve_config(config: &mut RepoConfig) -> Result<(), ConfigError> {
371 let mut relations = Vec::new();
372 for (id, artifact) in &config.artifacts {
373 let error = |keys: &[&str], message: String| {
374 ConfigError::declaration(config.root.join(artifact.declaration_path()), keys, message)
375 };
376 for (name, tool) in &artifact.views.agent_tools {
377 if let crate::config::AgentTool::Builtin(tool) = tool
378 && let Some(args) = &tool.args
379 {
380 let scope = artifact_scope(config, &[id]).map_err(|failure| {
381 error(&["views", "agent_tools", name, "args"], failure.to_string())
382 })?;
383 let owner = ArtifactId::new(id.as_str()).map_err(|failure| {
384 error(&["views", "agent_tools", name, "args"], failure.to_string())
385 })?;
386 artifactize_tools::builtin::validate_target(
387 tool.builtin,
388 args,
389 &config.root,
390 &scope.tool_scope(),
391 &owner,
392 )
393 .map_err(|failure| error(&["views", "agent_tools", name, "args"], failure))?;
394 }
395 if let crate::config::AgentTool::Command(tool) = tool
396 && tool.protocol == crate::config::ToolProtocol::Json
397 {
398 for argument in &tool.args {
399 if let Argument::Reference {
400 name: ref_name,
401 path,
402 ..
403 } = argument
404 {
405 let target = reference_target(config, id, ref_name).map_err(|failure| {
406 error(
407 &["views", "agent_tools", name, "args"],
408 format!("Agent tool {name}: {failure}"),
409 )
410 })?;
411 reference_path(config, target, path).map_err(|failure| {
412 error(
413 &["views", "agent_tools", name, "args"],
414 format!("Agent tool {name}: {failure}"),
415 )
416 })?;
417 }
418 }
419 }
420 }
421 if let Some(Fingerprint::Script { args, .. }) = &artifact.fingerprint {
422 for argument in args {
423 if let Argument::Reference {
424 name: ref_name,
425 path,
426 ..
427 } = argument
428 {
429 let target = reference_target(config, id, ref_name).map_err(|failure| {
430 error(
431 &["fingerprint", "script", "args"],
432 format!("fingerprint.script: {failure}"),
433 )
434 })?;
435 reference_path(config, target, path).map_err(|failure| {
436 error(
437 &["fingerprint", "script", "args"],
438 format!("fingerprint.script: {failure}"),
439 )
440 })?;
441 }
442 }
443 }
444 }
445 for (id, artifact) in &config.artifacts {
446 let Some(Fingerprint::Artifactsum { files: inputs, .. }) = &artifact.fingerprint else {
447 continue;
448 };
449 let error = |message: String| {
450 ConfigError::declaration(
451 config.root.join(artifact.declaration_path()),
452 &["fingerprint", "files"],
453 format!("fingerprint.files: {message}"),
454 )
455 };
456 let scope = artifact_scope(config, &[id]).map_err(|failure| error(failure.0))?;
457 for input in inputs.iter().filter(|input| *input != ".") {
458 if input.ends_with(".artf")
459 && !crate::platform::path_kind(&config.root.join(artifact.folder()).join(input))
460 .is_ok_and(|kind| kind == crate::platform::FileKind::Directory)
461 {
462 return Err(error(
463 "Artifact declarations (*.artf) cannot be explicit artifactsum inputs; declarations are excluded from artifactsum."
464 .into(),
465 ));
466 }
467 let location = scope
468 .resolve_path(id, input)
469 .map_err(|failure| error(failure.0))?;
470 if location.artifact_id.as_str() != id.as_str() {
471 return Err(error(format!(
472 "{input} belongs to Artifact {}; a fingerprint covers only its own Artifact's files.",
473 location.artifact_id
474 )));
475 }
476 }
477 }
478 let everything = Scope {
479 artifacts: config
480 .artifacts
481 .iter()
482 .map(|(id, artifact)| (id.clone(), artifact))
483 .collect(),
484 }
485 .tool_scope();
486 let mut resolved = Vec::new();
487 for eval in &config.evals {
488 let error = |field: &[&str], failure: ScopeError| {
489 let mut keys = vec!["evals", eval.declaration.id.as_ref()];
490 keys.extend_from_slice(field);
491 ConfigError::declaration(
492 config
493 .root
494 .join(config.artifacts[&eval.target].declaration_path()),
495 &keys,
496 format!("Eval {}: {failure}", eval.id),
497 )
498 };
499 let mut references = BTreeMap::new();
500 let mut deps = BTreeSet::new();
501 let instruction = eval
502 .declaration
503 .payload()
504 .map_or("", |payload| payload.instruction.as_str());
505 for reference in instruction::references(instruction) {
506 let name = reference.name;
507 let source = reference_target(config, &eval.target, &name)
508 .map_err(|failure| error(&["payload", "instruction"], failure))?;
509 if instruction[reference.end..].starts_with('/') {
510 reference_path(config, source, "/")
511 .map_err(|failure| error(&["payload", "instruction"], failure))?;
512 }
513 if references.insert(name.clone(), source.clone()).is_some() {
514 continue;
515 }
516 if source != &eval.target {
517 deps.insert(source.clone());
518 relations.push(Relation {
519 source: source.clone(),
520 target: eval.target.clone(),
521 kind: RelationKind::Instruction {
522 eval_id: eval.id.clone(),
523 name,
524 },
525 });
526 }
527 }
528 if let Profile::Runtime { args, .. } = eval.declaration.profile() {
529 for (index, argument) in args.iter().enumerate() {
530 let Argument::Reference {
531 name: ref_name,
532 path,
533 ..
534 } = argument
535 else {
536 continue;
537 };
538 let source = reference_target(config, &eval.target, ref_name)
539 .map_err(|failure| error(&["profile", "args"], failure))?;
540 reference_path(config, source, path)
541 .map_err(|failure| error(&["profile", "args"], failure))?;
542 nested_path(&everything, source, path)
543 .map_err(|failure| error(&["profile", "args"], failure))?;
544 if source != &eval.target {
545 deps.insert(source.clone());
546 relations.push(Relation {
547 source: source.clone(),
548 target: eval.target.clone(),
549 kind: RelationKind::Argument {
550 eval_id: eval.id.clone(),
551 index,
552 name: ref_name.clone(),
553 path: path.clone(),
554 },
555 });
556 }
557 }
558 }
559 resolved.push((references, deps.into_iter().collect()));
560 }
561 for (eval, (references, deps)) in config.evals.iter_mut().zip(resolved) {
562 eval.references = references;
563 eval.deps = deps;
564 }
565 for artifact in config.artifacts.values() {
566 for part in &artifact.parts {
567 let error = |message: String| {
568 ConfigError::declaration(
569 config.root.join(artifact.declaration_path()),
570 &["parts"],
571 message,
572 )
573 };
574 match config.artifacts.get(part) {
575 None => return Err(error(format!("Unknown Artifact {part}."))),
576 Some(part) if part.kind == crate::config::ArtifactKind::Graph => {
577 return Err(error(format!(
578 "The graph Artifact {} is a part of nothing.",
579 part.name
580 )));
581 }
582 Some(_) => relations.push(Relation {
583 source: part.clone(),
584 target: artifact.name.clone(),
585 kind: RelationKind::Part,
586 }),
587 }
588 }
589 }
590 parts_acyclic(config)?;
591 config.relations = relations;
592 human::validate_references(config)?;
593 crate::graph::Graph::new(config).map_err(|error| ConfigError::new(&config.root, error))?;
594 Ok(())
595}
596
597fn parts_acyclic(config: &RepoConfig) -> Result<(), ConfigError> {
599 for artifact in config.artifacts.values() {
600 if config.descendants(&artifact.name).contains(&&artifact.name) {
601 return Err(ConfigError::declaration(
602 config.root.join(artifact.declaration_path()),
603 &["parts"],
604 format!("{} is a part of itself through its parts.", artifact.name),
605 ));
606 }
607 }
608 Ok(())
609}
610
611#[cfg(test)]
612mod tests;