Skip to main content

artifactize_core/
scope.rs

1//! 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    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    /// Snapshot only access data; composition and eval dependencies are decided here.
61    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
90/// The tool-side ID of a configured Artifact. Configuration validates every Artifact name
91/// and child, so a name that fails here is a bug in that validation.
92pub(crate) fn tool_id(name: &ArtifactName) -> ArtifactId {
93    ArtifactId::new(name.as_str()).expect("configuration validates Artifact names")
94}
95
96/// Revalidate file targets at every preparation/read boundary, without following links.
97pub(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
135/// Composition grants access; a referenced Artifact's Eval instructions do not.
136pub 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
155/// Validate scope-relative command paths before process lookup. Bare names and absolute
156/// paths stay literal here; preflight and both launchers use the shared PATH/PATHEXT rule.
157pub(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        // Even an extensionless declaration must not hide a link behind PATHEXT.
177        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                // A suffix the lookup added is matched to the entry's actual spelling.
183                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
215/// PATHEXT supplies a suffix, not a model-authored name: use its actual directory-entry
216/// spelling before strict scoped validation. Parent components retain their input spelling.
217fn 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
240/// Admit an owner plus the explicit references in its argv, as an Eval's scope admits its deps.
241pub 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
261/// Presentation tokens only: never expand content or mutate the authored payload.
262/// Artifact tokens carry names already validated by configuration.
263pub 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
306/// Resolve `{name}[/path]` and `--flag={name}[/path]` operands to existing scoped inputs.
307/// Other arguments, including escaped braces, stay literal. Never interpolate the command.
308/// Call at execution preparation, after static config validation; it opens input metadata.
309pub 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
332/// Static relationships only; scheduling and dependency closure belong to graph.
333pub(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;