Skip to main content

artifactize_core/
scope.rs

1//! Mounts, aliases, artifact references, and canonical scoped paths.
2
3use 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    /// Input Artifact.
26    pub source: ArtifactName,
27    /// Consumer Artifact.
28    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    /// Snapshot only access data; composition and eval dependencies are decided here.
69    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
98/// The tool-side ID of a configured Artifact. Configuration validates every Artifact name,
99/// mount target and child, so a name that fails here is a bug in that validation.
100pub(crate) fn tool_id(name: &ArtifactName) -> ArtifactId {
101    ArtifactId::new(name.as_str()).expect("configuration validates Artifact names")
102}
103
104/// Revalidate file targets at every preparation/read boundary, without following links.
105pub(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
143/// Composition grants access; a referenced Artifact's Eval instructions do not.
144pub 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
164/// Validate scope-relative command paths before process lookup. Bare names and absolute
165/// paths stay literal here; preflight and both launchers use the shared PATH/PATHEXT rule.
166pub(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        // Even an extensionless declaration must not hide a link behind PATHEXT.
189        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                // A suffix the lookup added is matched to the entry's actual spelling.
195                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
227/// PATHEXT supplies a suffix, not a model-authored name: use its actual directory-entry
228/// spelling before strict scoped validation. Parent components retain their input spelling.
229fn 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
253/// Admit an owner plus the explicit references in its argv, as an Eval's scope admits its deps.
254pub 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
274/// Presentation tokens only: never expand content or mutate the authored payload.
275/// Artifact tokens carry names already validated by configuration.
276pub 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
323/// Resolve `{name}[/path]` and `--flag={name}[/path]` operands to existing scoped inputs.
324/// Other arguments, including escaped braces, stay literal. Never interpolate the command.
325/// Call at execution preparation, after static config validation; it opens input metadata.
326pub 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
349/// Static relationships only; scheduling and dependency closure belong to graph.
350pub(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;