Skip to main content

opal/pipeline/
artifacts.rs

1use crate::git;
2use crate::model::{ArtifactSourceOutcome, JobSpec};
3use crate::naming::{job_name_slug, project_slug};
4use crate::pipeline::VolumeMount;
5use anyhow::{Context, Result, anyhow};
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::sync::{Arc, Mutex};
12use tracing::warn;
13
14#[derive(Debug, Clone)]
15pub struct ArtifactManager {
16    root: PathBuf,
17}
18
19impl ArtifactManager {
20    pub fn new(root: PathBuf) -> Self {
21        Self { root }
22    }
23
24    pub fn prepare_targets(&self, job: &JobSpec) -> Result<()> {
25        if job.artifacts.paths.is_empty()
26            && !job.artifacts.untracked
27            && job.artifacts.report_dotenv.is_none()
28        {
29            return Ok(());
30        }
31        let root = self.job_artifacts_root(&job.name);
32        fs::create_dir_all(&root)
33            .with_context(|| format!("failed to prepare artifacts for {}", job.name))?;
34
35        for relative in &job.artifacts.paths {
36            let host = self.job_artifact_host_path(&job.name, relative);
37            match artifact_kind(relative) {
38                ArtifactPathKind::Directory => {
39                    fs::create_dir_all(&host).with_context(|| {
40                        format!("failed to prepare artifact directory {}", host.display())
41                    })?;
42                }
43                ArtifactPathKind::File => {
44                    if let Some(parent) = host.parent() {
45                        fs::create_dir_all(parent).with_context(|| {
46                            format!("failed to prepare artifact parent {}", parent.display())
47                        })?;
48                    }
49                }
50            }
51        }
52
53        if let Some(relative) = &job.artifacts.report_dotenv {
54            let host = self.job_artifact_host_path(&job.name, relative);
55            if let Some(parent) = host.parent() {
56                fs::create_dir_all(parent).with_context(|| {
57                    format!(
58                        "failed to prepare dotenv artifact parent {}",
59                        parent.display()
60                    )
61                })?;
62            }
63        }
64
65        Ok(())
66    }
67
68    pub fn job_mount_specs(&self, job: &JobSpec) -> Vec<(PathBuf, PathBuf)> {
69        use std::collections::HashSet;
70
71        let mut specs = Vec::new();
72        let mut seen = HashSet::new();
73        for relative in &job.artifacts.paths {
74            let rel_path = artifact_relative_path(relative);
75            let mount_rel = match artifact_kind(relative) {
76                ArtifactPathKind::Directory => rel_path.clone(),
77                ArtifactPathKind::File => match rel_path.parent() {
78                    Some(parent) if parent != Path::new("") && parent != Path::new(".") => {
79                        parent.to_path_buf()
80                    }
81                    _ => continue,
82                },
83            };
84            if seen.insert(mount_rel.clone()) {
85                let host = self.job_artifacts_root(&job.name).join(&mount_rel);
86                specs.push((host, mount_rel));
87            }
88        }
89        specs
90    }
91
92    pub fn collect_declared(
93        &self,
94        job: &JobSpec,
95        workspace: &Path,
96        mounts: &[VolumeMount],
97        container_root: &Path,
98    ) -> Result<()> {
99        let exclude = build_exclude_matcher(&job.artifacts.exclude)?;
100        for relative in &job.artifacts.paths {
101            let src = resolve_artifact_source(workspace, mounts, container_root, relative);
102            if !src.exists() {
103                continue;
104            }
105            let dest = self.job_artifact_host_path(&job.name, relative);
106            copy_declared_path(&src, &dest, relative, exclude.as_ref())?;
107        }
108        Ok(())
109    }
110
111    pub fn dependency_mount_specs(
112        &self,
113        job_name: &str,
114        job: Option<&JobSpec>,
115        outcome: Option<ArtifactSourceOutcome>,
116        optional: bool,
117    ) -> Vec<(PathBuf, PathBuf)> {
118        let Some(dep_job) = job else {
119            return Vec::new();
120        };
121        let mut specs = Vec::new();
122        for relative in &dep_job.artifacts.paths {
123            let host = self.job_artifact_host_path(job_name, relative);
124            if !host.exists() {
125                if !optional {
126                    warn!(job = job_name, path = %relative.display(), "artifact missing");
127                }
128                continue;
129            }
130            if !dep_job.artifacts.when.includes(outcome) && !artifact_path_has_content(&host) {
131                continue;
132            }
133            specs.push((host, relative.clone()));
134        }
135        if dep_job.artifacts.untracked {
136            for relative in self.read_untracked_manifest(job_name) {
137                let host = self.job_artifact_host_path(job_name, &relative);
138                if !host.exists() {
139                    continue;
140                }
141                if !dep_job.artifacts.when.includes(outcome) && !artifact_path_has_content(&host) {
142                    continue;
143                }
144                specs.push((host, relative));
145            }
146        }
147
148        specs
149    }
150
151    pub fn job_artifact_host_path(&self, job_name: &str, artifact: &Path) -> PathBuf {
152        self.job_artifacts_root(job_name)
153            .join(artifact_relative_path(artifact))
154    }
155
156    pub fn collect_dotenv_report(
157        &self,
158        job: &JobSpec,
159        workspace: &Path,
160        mounts: &[VolumeMount],
161        container_root: &Path,
162    ) -> Result<()> {
163        let Some(relative) = &job.artifacts.report_dotenv else {
164            return Ok(());
165        };
166        let src = resolve_artifact_source(workspace, mounts, container_root, relative);
167        if !src.exists() {
168            return Ok(());
169        }
170        let dest = self.job_artifact_host_path(&job.name, relative);
171        if let Some(parent) = dest.parent() {
172            fs::create_dir_all(parent)
173                .with_context(|| format!("failed to create {}", parent.display()))?;
174        }
175        fs::copy(&src, &dest)
176            .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
177        Ok(())
178    }
179
180    pub fn job_artifacts_root(&self, job_name: &str) -> PathBuf {
181        self.root.join(job_name_slug(job_name)).join("artifacts")
182    }
183
184    pub fn job_dependency_root(&self, job_name: &str) -> PathBuf {
185        self.root.join(job_name_slug(job_name)).join("dependencies")
186    }
187
188    pub fn job_dependency_host_path(&self, job_name: &str, artifact: &Path) -> PathBuf {
189        self.job_dependency_root(job_name)
190            .join(artifact_relative_path(artifact))
191    }
192
193    pub fn collect_untracked(&self, job: &JobSpec, workspace: &Path) -> Result<()> {
194        if !job.artifacts.untracked {
195            return Ok(());
196        }
197
198        let exclude = build_exclude_matcher(&job.artifacts.exclude)?;
199        let explicit_paths = &job.artifacts.paths;
200        let mut collected = Vec::new();
201        for relative in git::untracked_files(workspace)? {
202            let relative = PathBuf::from(relative);
203            if path_is_covered_by_explicit_artifacts(&relative, explicit_paths) {
204                continue;
205            }
206            if should_exclude(&relative, exclude.as_ref()) {
207                continue;
208            }
209
210            let src = workspace.join(&relative);
211            if !src.exists() {
212                continue;
213            }
214            copy_untracked_entry(
215                workspace,
216                &src,
217                self.job_artifact_host_path(&job.name, &relative),
218                &relative,
219                exclude.as_ref(),
220                &mut collected,
221            )?;
222        }
223
224        collected.sort();
225        collected.dedup();
226        self.write_untracked_manifest(&job.name, &collected)
227    }
228
229    fn write_untracked_manifest(&self, job_name: &str, paths: &[PathBuf]) -> Result<()> {
230        let manifest = self.job_untracked_manifest_path(job_name);
231        if let Some(parent) = manifest.parent() {
232            fs::create_dir_all(parent)
233                .with_context(|| format!("failed to create {}", parent.display()))?;
234        }
235        let content = if paths.is_empty() {
236            String::new()
237        } else {
238            let mut body = paths
239                .iter()
240                .map(|path| path.to_string_lossy().to_string())
241                .collect::<Vec<_>>()
242                .join("\n");
243            body.push('\n');
244            body
245        };
246        fs::write(&manifest, content)
247            .with_context(|| format!("failed to write {}", manifest.display()))
248    }
249
250    fn read_untracked_manifest(&self, job_name: &str) -> Vec<PathBuf> {
251        let manifest = self.job_untracked_manifest_path(job_name);
252        let Ok(contents) = fs::read_to_string(&manifest) else {
253            return Vec::new();
254        };
255        contents
256            .lines()
257            .filter(|line| !line.trim().is_empty())
258            .map(PathBuf::from)
259            .collect()
260    }
261
262    fn job_untracked_manifest_path(&self, job_name: &str) -> PathBuf {
263        self.root
264            .join(job_name_slug(job_name))
265            .join("untracked-manifest.txt")
266    }
267}
268
269#[derive(Debug, Clone, Copy)]
270pub enum ArtifactPathKind {
271    File,
272    Directory,
273}
274
275fn artifact_relative_path(artifact: &Path) -> PathBuf {
276    use std::path::Component;
277
278    let mut rel = PathBuf::new();
279    for component in artifact.components() {
280        match component {
281            Component::RootDir | Component::CurDir => continue,
282            Component::ParentDir => continue,
283            Component::Prefix(prefix) => rel.push(prefix.as_os_str()),
284            Component::Normal(seg) => rel.push(seg),
285        }
286    }
287
288    if rel.as_os_str().is_empty() {
289        rel.push("artifact");
290    }
291    rel
292}
293
294fn resolve_artifact_source(
295    workspace: &Path,
296    mounts: &[VolumeMount],
297    container_root: &Path,
298    relative: &Path,
299) -> PathBuf {
300    let container_path = container_root.join(relative);
301    mounts
302        .iter()
303        .filter_map(|mount| resolve_mount_source(mount, &container_path))
304        .max_by_key(|(depth, _)| *depth)
305        .map(|(_, path)| path)
306        .unwrap_or_else(|| workspace.join(relative))
307}
308
309fn resolve_mount_source(mount: &VolumeMount, container_path: &Path) -> Option<(usize, PathBuf)> {
310    if container_path == mount.container {
311        return Some((mount.container.components().count(), mount.host.clone()));
312    }
313    if mount.host.is_dir() && container_path.starts_with(&mount.container) {
314        let relative = container_path.strip_prefix(&mount.container).ok()?;
315        return Some((
316            mount.container.components().count(),
317            mount.host.join(relative),
318        ));
319    }
320    None
321}
322
323fn copy_declared_path(
324    src: &Path,
325    dest: &Path,
326    relative: &Path,
327    exclude: Option<&GlobSet>,
328) -> Result<()> {
329    let metadata =
330        fs::symlink_metadata(src).with_context(|| format!("failed to stat {}", src.display()))?;
331    if metadata.is_dir() {
332        fs::create_dir_all(dest).with_context(|| format!("failed to create {}", dest.display()))?;
333        for entry in
334            fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
335        {
336            let entry = entry?;
337            let child_src = entry.path();
338            let child_rel = relative.join(entry.file_name());
339            let child_dest = dest.join(entry.file_name());
340            copy_declared_path(&child_src, &child_dest, &child_rel, exclude)?;
341        }
342        return Ok(());
343    }
344    if exclude.is_some_and(|glob| glob.is_match(relative)) {
345        return Ok(());
346    }
347    if let Some(parent) = dest.parent() {
348        fs::create_dir_all(parent)
349            .with_context(|| format!("failed to create {}", parent.display()))?;
350    }
351    fs::copy(src, dest)
352        .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
353    Ok(())
354}
355
356fn artifact_kind(path: &Path) -> ArtifactPathKind {
357    let text = path.to_string_lossy();
358    if text.ends_with(std::path::MAIN_SEPARATOR) {
359        ArtifactPathKind::Directory
360    } else {
361        ArtifactPathKind::File
362    }
363}
364
365fn artifact_path_has_content(path: &Path) -> bool {
366    match fs::metadata(path) {
367        Ok(metadata) if metadata.is_file() => true,
368        Ok(metadata) if metadata.is_dir() => fs::read_dir(path)
369            .ok()
370            .and_then(|mut entries| entries.next())
371            .is_some(),
372        _ => false,
373    }
374}
375
376fn build_exclude_matcher(patterns: &[String]) -> Result<Option<GlobSet>> {
377    if patterns.is_empty() {
378        return Ok(None);
379    }
380
381    let mut builder = GlobSetBuilder::new();
382    for pattern in patterns {
383        builder.add(
384            Glob::new(pattern)
385                .with_context(|| format!("invalid artifacts.exclude pattern '{pattern}'"))?,
386        );
387    }
388    Ok(Some(builder.build()?))
389}
390
391fn should_exclude(path: &Path, exclude: Option<&GlobSet>) -> bool {
392    exclude.is_some_and(|glob| glob.is_match(path))
393}
394
395fn path_is_covered_by_explicit_artifacts(path: &Path, explicit_paths: &[PathBuf]) -> bool {
396    explicit_paths
397        .iter()
398        .any(|artifact| match artifact_kind(artifact) {
399            ArtifactPathKind::Directory => {
400                let base = artifact_relative_path(artifact);
401                path == base || path.starts_with(&base)
402            }
403            ArtifactPathKind::File => path == artifact_relative_path(artifact),
404        })
405}
406
407fn copy_untracked_entry(
408    workspace: &Path,
409    src: &Path,
410    dest: PathBuf,
411    relative: &Path,
412    exclude: Option<&GlobSet>,
413    collected: &mut Vec<PathBuf>,
414) -> Result<()> {
415    let metadata =
416        fs::symlink_metadata(src).with_context(|| format!("failed to stat {}", src.display()))?;
417    if metadata.is_dir() {
418        for entry in
419            fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
420        {
421            let entry = entry?;
422            let child_src = entry.path();
423            let child_relative = match child_src.strip_prefix(workspace) {
424                Ok(rel) => rel.to_path_buf(),
425                Err(_) => continue,
426            };
427            let child_dest = dest.join(entry.file_name());
428            copy_untracked_entry(
429                workspace,
430                &child_src,
431                child_dest,
432                &child_relative,
433                exclude,
434                collected,
435            )?;
436        }
437        return Ok(());
438    }
439
440    if should_exclude(relative, exclude) {
441        return Ok(());
442    }
443    if let Some(parent) = dest.parent() {
444        fs::create_dir_all(parent)
445            .with_context(|| format!("failed to create {}", parent.display()))?;
446    }
447    fs::copy(src, &dest)
448        .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
449    collected.push(relative.to_path_buf());
450    Ok(())
451}
452
453#[derive(Clone, Debug)]
454pub struct ExternalArtifactsManager {
455    inner: Arc<ExternalArtifactsInner>,
456}
457
458#[derive(Debug)]
459struct ExternalArtifactsInner {
460    root: PathBuf,
461    base_url: String,
462    token: String,
463    cache: Mutex<HashMap<String, PathBuf>>,
464}
465
466impl ExternalArtifactsManager {
467    pub fn new(root: PathBuf, base_url: String, token: String) -> Self {
468        let inner = ExternalArtifactsInner {
469            root,
470            base_url,
471            token,
472            cache: Mutex::new(HashMap::new()),
473        };
474        Self {
475            inner: Arc::new(inner),
476        }
477    }
478
479    pub fn ensure_artifacts(&self, project: &str, job: &str, reference: &str) -> Result<PathBuf> {
480        let key = format!("{project}::{reference}::{job}");
481        if let Ok(cache) = self.inner.cache.lock()
482            && let Some(path) = cache.get(&key)
483            && path.exists()
484        {
485            return Ok(path.clone());
486        }
487
488        let target = self.external_root(project, job, reference);
489        if target.exists() {
490            fs::remove_dir_all(&target)
491                .with_context(|| format!("failed to clear {}", target.display()))?;
492        }
493        fs::create_dir_all(&target)
494            .with_context(|| format!("failed to create {}", target.display()))?;
495        let archive_path = target.join("artifacts.zip");
496        self.download_artifacts(project, job, reference, &archive_path)?;
497        self.extract_artifacts(&archive_path, &target)?;
498        let _ = fs::remove_file(&archive_path);
499
500        if let Ok(mut cache) = self.inner.cache.lock() {
501            cache.insert(key, target.clone());
502        }
503
504        Ok(target)
505    }
506
507    fn external_root(&self, project: &str, job: &str, reference: &str) -> PathBuf {
508        let project_slug = project_slug(project);
509        let reference_slug = sanitize_reference(reference);
510        self.inner
511            .root
512            .join("external")
513            .join(project_slug)
514            .join(reference_slug)
515            .join(job_name_slug(job))
516    }
517
518    fn download_artifacts(
519        &self,
520        project: &str,
521        job: &str,
522        reference: &str,
523        dest: &Path,
524    ) -> Result<()> {
525        let base = self.inner.base_url.trim_end_matches('/');
526        let project_id = percent_encode(project);
527        let ref_id = percent_encode(reference);
528        let job_name = percent_encode(job);
529        let url = format!(
530            "{base}/api/v4/projects/{project_id}/jobs/artifacts/{ref_id}/download?job={job_name}"
531        );
532        let status = Command::new("curl")
533            .arg("--fail")
534            .arg("-sS")
535            .arg("-L")
536            .arg("-H")
537            .arg(format!("PRIVATE-TOKEN: {}", self.inner.token))
538            .arg("-o")
539            .arg(dest)
540            .arg(&url)
541            .status()
542            .with_context(|| "failed to invoke curl to download artifacts")?;
543        if !status.success() {
544            return Err(anyhow!(
545                "curl failed to download artifacts from {} (status {})",
546                url,
547                status.code().unwrap_or(-1)
548            ));
549        }
550        Ok(())
551    }
552
553    fn extract_artifacts(&self, archive: &Path, dest: &Path) -> Result<()> {
554        let unzip_status = Command::new("unzip")
555            .arg("-q")
556            .arg("-o")
557            .arg(archive)
558            .arg("-d")
559            .arg(dest)
560            .status();
561        match unzip_status {
562            Ok(status) if status.success() => return Ok(()),
563            Ok(_) | Err(_) => {
564                // fallback to python's zipfile
565                let script =
566                    "import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])";
567                let status = Command::new("python3")
568                    .arg("-c")
569                    .arg(script)
570                    .arg(archive)
571                    .arg(dest)
572                    .status()
573                    .with_context(|| "failed to invoke python3 to extract artifacts")?;
574                if status.success() {
575                    return Ok(());
576                }
577            }
578        }
579        Err(anyhow!(
580            "unable to extract artifacts archive {}",
581            archive.display()
582        ))
583    }
584}
585
586fn percent_encode(value: &str) -> String {
587    let mut encoded = String::new();
588    for byte in value.bytes() {
589        match byte {
590            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
591                encoded.push(byte as char)
592            }
593            _ => encoded.push_str(&format!("%{:02X}", byte)),
594        }
595    }
596    encoded
597}
598
599fn sanitize_reference(reference: &str) -> String {
600    let mut slug = String::new();
601    for ch in reference.chars() {
602        if ch.is_ascii_alphanumeric() {
603            slug.push(ch.to_ascii_lowercase());
604        } else if matches!(ch, '-' | '_' | '.') {
605            slug.push(ch);
606        } else {
607            slug.push('-');
608        }
609    }
610    if slug.is_empty() {
611        slug.push_str("ref");
612    }
613    slug
614}
615
616#[cfg(test)]
617mod tests {
618    use super::{
619        ArtifactManager, ArtifactPathKind, artifact_kind, artifact_path_has_content,
620        path_is_covered_by_explicit_artifacts, resolve_artifact_source,
621    };
622    use crate::model::{
623        ArtifactSourceOutcome, ArtifactSpec, ArtifactWhenSpec, JobSpec, RetryPolicySpec,
624    };
625    use crate::pipeline::VolumeMount;
626    use std::collections::HashMap;
627    use std::fs;
628    use std::path::{Path, PathBuf};
629    use std::time::{SystemTime, UNIX_EPOCH};
630
631    #[test]
632    fn artifact_path_kind_treats_trailing_separator_as_directory() {
633        assert!(matches!(
634            artifact_kind(std::path::Path::new("tests-temp/build/")),
635            ArtifactPathKind::Directory
636        ));
637    }
638
639    #[test]
640    fn artifact_path_has_content_requires_directory_entries() {
641        let root = temp_path("artifact-content");
642        fs::create_dir_all(&root).expect("create dir");
643        assert!(!artifact_path_has_content(&root));
644        fs::write(root.join("marker.txt"), "ok").expect("write marker");
645        assert!(artifact_path_has_content(&root));
646        let _ = fs::remove_dir_all(root);
647    }
648
649    #[test]
650    fn dependency_mount_specs_allow_populated_artifacts_without_recorded_outcome() {
651        let root = temp_path("artifact-presence");
652        let manager = ArtifactManager::new(root.clone());
653        let relative = PathBuf::from("tests-temp/build/");
654        let job = job(
655            "build",
656            vec![relative.clone()],
657            Vec::new(),
658            false,
659            ArtifactWhenSpec::OnSuccess,
660        );
661        let host = manager.job_artifact_host_path("build", &relative);
662        fs::create_dir_all(&host).expect("create artifact dir");
663        fs::write(host.join("linux-release.txt"), "release").expect("write artifact");
664
665        let specs = manager.dependency_mount_specs("build", Some(&job), None, false);
666
667        assert_eq!(specs.len(), 1);
668        assert_eq!(specs[0].0, host);
669
670        let _ = fs::remove_dir_all(root);
671    }
672
673    #[test]
674    fn artifact_when_matches_expected_outcomes() {
675        assert!(ArtifactWhenSpec::Always.includes(None));
676        assert!(ArtifactWhenSpec::Always.includes(Some(ArtifactSourceOutcome::Success)));
677        assert!(ArtifactWhenSpec::OnSuccess.includes(Some(ArtifactSourceOutcome::Success)));
678        assert!(!ArtifactWhenSpec::OnSuccess.includes(Some(ArtifactSourceOutcome::Failed)));
679        assert!(ArtifactWhenSpec::OnFailure.includes(Some(ArtifactSourceOutcome::Failed)));
680        assert!(!ArtifactWhenSpec::OnFailure.includes(Some(ArtifactSourceOutcome::Skipped)));
681        assert!(!ArtifactWhenSpec::OnFailure.includes(None));
682    }
683
684    #[test]
685    fn collect_untracked_includes_ignored_workspace_files() {
686        let root = temp_path("artifact-untracked");
687        let workspace = root.join("workspace");
688        fs::create_dir_all(&workspace).expect("create workspace");
689        let repo = git2::Repository::init(&workspace).expect("init repo");
690        fs::write(workspace.join("README.md"), "opal\n").expect("write readme");
691        fs::write(workspace.join(".gitignore"), "tests-temp/\n").expect("write ignore");
692        let mut index = repo.index().expect("open index");
693        index
694            .add_path(Path::new("README.md"))
695            .expect("add readme to index");
696        index
697            .add_path(Path::new(".gitignore"))
698            .expect("add ignore to index");
699        let tree_id = index.write_tree().expect("write tree");
700        let tree = repo.find_tree(tree_id).expect("find tree");
701        let sig = git2::Signature::now("Opal Tests", "opal@example.com").expect("signature");
702        repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
703            .expect("commit");
704
705        fs::create_dir_all(workspace.join("tests-temp")).expect("create ignored dir");
706        fs::write(workspace.join("tests-temp/generated.txt"), "hello").expect("write ignored");
707        fs::write(workspace.join("scratch.txt"), "hi").expect("write untracked");
708
709        let manager = ArtifactManager::new(root.join("artifacts"));
710        let job = job(
711            "build",
712            Vec::new(),
713            vec!["tests-temp/**/*.log".into()],
714            true,
715            ArtifactWhenSpec::OnSuccess,
716        );
717
718        manager
719            .prepare_targets(&job)
720            .expect("prepare artifact targets");
721        manager
722            .collect_untracked(&job, &workspace)
723            .expect("collect untracked artifacts");
724
725        let manifest = manager.read_untracked_manifest("build");
726        assert!(manifest.iter().any(|path| path == Path::new("scratch.txt")));
727        assert!(
728            manifest
729                .iter()
730                .any(|path| path == Path::new("tests-temp/generated.txt"))
731        );
732        assert!(
733            manager
734                .job_artifact_host_path("build", Path::new("scratch.txt"))
735                .exists()
736        );
737        assert!(
738            manager
739                .job_artifact_host_path("build", Path::new("tests-temp/generated.txt"))
740                .exists()
741        );
742
743        let _ = fs::remove_dir_all(root);
744    }
745
746    #[test]
747    fn resolve_artifact_source_prefers_more_specific_mount_over_workspace_root() {
748        let root = temp_path("artifact-mounted-source");
749        let workspace = root.join("workspace");
750        let cache_host = root.join("cache-target");
751        fs::create_dir_all(workspace.join("target/release")).expect("create workspace target");
752        fs::create_dir_all(cache_host.join("release")).expect("create cache target");
753        fs::write(cache_host.join("release/opal"), "binary").expect("write cached binary");
754
755        let mounts = vec![
756            VolumeMount {
757                host: workspace.clone(),
758                container: PathBuf::from("/builds/opal"),
759                read_only: false,
760            },
761            VolumeMount {
762                host: cache_host.clone(),
763                container: PathBuf::from("/builds/opal/target"),
764                read_only: false,
765            },
766        ];
767
768        let resolved = resolve_artifact_source(
769            &workspace,
770            &mounts,
771            Path::new("/builds/opal"),
772            Path::new("target/release/opal"),
773        );
774
775        assert_eq!(resolved, cache_host.join("release/opal"));
776
777        let _ = fs::remove_dir_all(root);
778    }
779
780    #[test]
781    fn path_is_covered_by_explicit_artifacts_matches_directory_and_file_paths() {
782        assert!(path_is_covered_by_explicit_artifacts(
783            Path::new("tests-temp/build/linux.txt"),
784            &[PathBuf::from("tests-temp/build/")]
785        ));
786        assert!(path_is_covered_by_explicit_artifacts(
787            Path::new("output/report.txt"),
788            &[PathBuf::from("output/report.txt")]
789        ));
790        assert!(!path_is_covered_by_explicit_artifacts(
791            Path::new("other.txt"),
792            &[PathBuf::from("output/report.txt")]
793        ));
794    }
795
796    fn job(
797        name: &str,
798        paths: Vec<PathBuf>,
799        exclude: Vec<String>,
800        untracked: bool,
801        when: ArtifactWhenSpec,
802    ) -> JobSpec {
803        JobSpec {
804            name: name.into(),
805            stage: "build".into(),
806            commands: vec!["echo ok".into()],
807            needs: Vec::new(),
808            explicit_needs: false,
809            dependencies: Vec::new(),
810            before_script: None,
811            after_script: None,
812            inherit_default_before_script: true,
813            inherit_default_after_script: true,
814            inherit_default_image: true,
815            inherit_default_cache: true,
816            inherit_default_services: true,
817            inherit_default_timeout: true,
818            inherit_default_retry: true,
819            inherit_default_interruptible: true,
820            when: None,
821            rules: Vec::new(),
822            only: Vec::new(),
823            except: Vec::new(),
824            artifacts: ArtifactSpec {
825                name: None,
826                paths,
827                exclude,
828                untracked,
829                when,
830                expire_in: None,
831                report_dotenv: None,
832            },
833            cache: Vec::new(),
834            image: None,
835            variables: HashMap::new(),
836            services: Vec::new(),
837            timeout: None,
838            retry: RetryPolicySpec::default(),
839            interruptible: false,
840            resource_group: None,
841            parallel: None,
842            tags: Vec::new(),
843            environment: None,
844        }
845    }
846
847    fn temp_path(prefix: &str) -> PathBuf {
848        let nanos = SystemTime::now()
849            .duration_since(UNIX_EPOCH)
850            .expect("system time before epoch")
851            .as_nanos();
852        std::env::temp_dir().join(format!("opal-{prefix}-{nanos}"))
853    }
854}