Skip to main content

zlayer_builder/backend/
buildah.rs

1//! Buildah-backed build backend.
2//!
3//! Wraps [`BuildahExecutor`] to implement the [`BuildBackend`] trait.
4//! Contains the full buildah build orchestration loop: stage walking,
5//! container creation, instruction execution, and commit.
6
7use std::collections::HashMap;
8use std::path::Path;
9use std::sync::mpsc;
10
11use tracing::{debug, info};
12
13use crate::buildah::{BuildahCommand, BuildahExecutor};
14use crate::builder::{BuildOptions, BuiltImage, RegistryAuth};
15use crate::dockerfile::{Dockerfile, ImageRef, Instruction, RunMount, Stage};
16use crate::error::{BuildError, Result};
17use crate::tui::BuildEvent;
18
19use super::BuildBackend;
20
21// ---------------------------------------------------------------------------
22// LayerCacheTracker (moved from builder.rs)
23// ---------------------------------------------------------------------------
24
25/// Tracks layer cache state during builds.
26///
27/// Maintains a mapping of instruction cache keys combined with base layer
28/// identifiers to determine if a layer was previously built and can be
29/// served from cache.
30#[derive(Debug, Default)]
31struct LayerCacheTracker {
32    /// Maps (`instruction_cache_key`, `base_layer_id`) -> `was_cached`
33    known_layers: HashMap<(String, String), bool>,
34}
35
36impl LayerCacheTracker {
37    fn new() -> Self {
38        Self::default()
39    }
40
41    #[allow(dead_code)]
42    fn is_cached(&self, instruction_key: &str, base_layer: &str) -> bool {
43        self.known_layers
44            .get(&(instruction_key.to_string(), base_layer.to_string()))
45            .copied()
46            .unwrap_or(false)
47    }
48
49    fn record(&mut self, instruction_key: String, base_layer: String, cached: bool) {
50        self.known_layers
51            .insert((instruction_key, base_layer), cached);
52    }
53
54    #[allow(dead_code, clippy::unused_self)]
55    fn detect_cache_hit(
56        &self,
57        _instruction: &Instruction,
58        _execution_time_ms: u64,
59        _output: &str,
60    ) -> bool {
61        // TODO: Implement cache hit detection heuristics
62        false
63    }
64}
65
66// ---------------------------------------------------------------------------
67// BuildahBackend
68// ---------------------------------------------------------------------------
69
70/// Build backend that delegates to the `buildah` CLI.
71pub struct BuildahBackend {
72    executor: BuildahExecutor,
73}
74
75impl BuildahBackend {
76    /// Try to create a new `BuildahBackend`.
77    ///
78    /// Returns `Ok` if buildah is found and functional, `Err` otherwise.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if buildah is not installed or is not responding.
83    pub async fn try_new() -> Result<Self> {
84        let executor = BuildahExecutor::new_async().await?;
85        if !executor.is_available().await {
86            return Err(crate::error::BuildError::BuildahNotFound {
87                message: "buildah is installed but not responding".into(),
88            });
89        }
90        Ok(Self { executor })
91    }
92
93    /// Create a new `BuildahBackend`, returning an error if buildah is not available.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if buildah is not installed or cannot be initialized.
98    pub async fn new() -> Result<Self> {
99        let executor = BuildahExecutor::new_async().await?;
100        Ok(Self { executor })
101    }
102
103    /// Create a `BuildahBackend` from an existing executor.
104    #[must_use]
105    pub fn with_executor(executor: BuildahExecutor) -> Self {
106        Self { executor }
107    }
108
109    /// Borrow the inner executor (useful for low-level operations).
110    #[must_use]
111    pub fn executor(&self) -> &BuildahExecutor {
112        &self.executor
113    }
114
115    // -----------------------------------------------------------------------
116    // Build orchestration helpers
117    // -----------------------------------------------------------------------
118
119    /// Resolve which stages need to be built.
120    #[allow(clippy::unused_self)]
121    fn resolve_stages<'a>(
122        &self,
123        dockerfile: &'a Dockerfile,
124        target: Option<&str>,
125    ) -> Result<Vec<&'a Stage>> {
126        if let Some(target) = target {
127            Self::resolve_target_stages(dockerfile, target)
128        } else {
129            Ok(dockerfile.stages.iter().collect())
130        }
131    }
132
133    /// Resolve stages needed for a specific target.
134    fn resolve_target_stages<'a>(
135        dockerfile: &'a Dockerfile,
136        target: &str,
137    ) -> Result<Vec<&'a Stage>> {
138        let target_stage = dockerfile
139            .get_stage(target)
140            .ok_or_else(|| BuildError::stage_not_found(target))?;
141
142        let mut stages: Vec<&Stage> = Vec::new();
143        for stage in &dockerfile.stages {
144            stages.push(stage);
145            if stage.index == target_stage.index {
146                break;
147            }
148        }
149        Ok(stages)
150    }
151
152    /// Resolve a base image reference to an actual image name.
153    ///
154    /// Resolution chain for short (unqualified) image names:
155    /// 1. Check `default_registry` for the image (if configured)
156    /// 2. Fall back to Docker Hub qualification (`docker.io/library/...`)
157    async fn resolve_base_image(
158        &self,
159        image_ref: &ImageRef,
160        stage_images: &HashMap<String, String>,
161        options: &BuildOptions,
162    ) -> Result<String> {
163        match image_ref {
164            ImageRef::Stage(name) => {
165                return stage_images
166                    .get(name)
167                    .cloned()
168                    .ok_or_else(|| BuildError::stage_not_found(name));
169            }
170            ImageRef::Scratch => return Ok("scratch".to_string()),
171            ImageRef::Registry { .. } => {}
172        }
173
174        // Check if name is already fully qualified (has registry hostname).
175        let is_qualified = match image_ref {
176            ImageRef::Registry { image, .. } => {
177                let first = image.split('/').next().unwrap_or("");
178                first.contains('.') || first.contains(':') || first == "localhost"
179            }
180            _ => false,
181        };
182
183        // For unqualified names, try default registry first.
184        if !is_qualified {
185            if let Some(resolved) = self.try_resolve_from_sources(image_ref, options).await {
186                return Ok(resolved);
187            }
188        }
189
190        // Fall back: qualify to docker.io and build the full string.
191        let qualified = image_ref.qualify();
192        match &qualified {
193            ImageRef::Registry { image, tag, digest } => {
194                let mut result = image.clone();
195                if let Some(t) = tag {
196                    result.push(':');
197                    result.push_str(t);
198                }
199                if let Some(d) = digest {
200                    result.push('@');
201                    result.push_str(d);
202                }
203                if tag.is_none() && digest.is_none() {
204                    result.push_str(":latest");
205                }
206                Ok(result)
207            }
208            _ => unreachable!("qualify() preserves Registry variant"),
209        }
210    }
211
212    /// Try to resolve an unqualified image from default registry.
213    ///
214    /// Returns `Some(fully_qualified_name)` if found, `None` to fall back to docker.io.
215    #[allow(clippy::unused_async)]
216    async fn try_resolve_from_sources(
217        &self,
218        image_ref: &ImageRef,
219        options: &BuildOptions,
220    ) -> Option<String> {
221        let (name, tag_str) = match image_ref {
222            ImageRef::Registry { image, tag, .. } => {
223                (image.as_str(), tag.as_deref().unwrap_or("latest"))
224            }
225            _ => return None,
226        };
227
228        // Check configured default registry.
229        if let Some(ref registry) = options.default_registry {
230            let qualified = format!("{registry}/{name}:{tag_str}");
231            debug!("Checking default registry for image: {}", qualified);
232            return Some(qualified);
233        }
234
235        None
236    }
237
238    /// Create a working container from an image.
239    async fn create_container(&self, image: &str, platform: Option<&str>) -> Result<String> {
240        let cmd = BuildahCommand::new("from")
241            .arg_opt("--platform", platform)
242            .arg(image);
243        let output = self.executor.execute_checked(&cmd).await?;
244        Ok(output.stdout.trim().to_string())
245    }
246
247    /// Commit a container to create an image.
248    async fn commit_container(
249        &self,
250        container: &str,
251        image_name: &str,
252        format: Option<&str>,
253        squash: bool,
254    ) -> Result<String> {
255        let cmd = BuildahCommand::commit_with_opts(container, image_name, format, squash);
256        let output = self.executor.execute_checked(&cmd).await?;
257        Ok(output.stdout.trim().to_string())
258    }
259
260    /// Tag an image with an additional tag.
261    async fn tag_image_internal(&self, image: &str, tag: &str) -> Result<()> {
262        let cmd = BuildahCommand::tag(image, tag);
263        self.executor.execute_checked(&cmd).await?;
264        Ok(())
265    }
266
267    /// Push an image to a registry.
268    async fn push_image_internal(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
269        let mut cmd = BuildahCommand::push(tag);
270        if let Some(auth) = auth {
271            cmd = cmd
272                .arg("--creds")
273                .arg(format!("{}:{}", auth.username, auth.password));
274        }
275        self.executor.execute_checked(&cmd).await?;
276        Ok(())
277    }
278
279    /// Send an event to the TUI (if configured).
280    fn send_event(event_tx: Option<&mpsc::Sender<BuildEvent>>, event: BuildEvent) {
281        if let Some(tx) = event_tx {
282            let _ = tx.send(event);
283        }
284    }
285}
286
287#[async_trait::async_trait]
288impl BuildBackend for BuildahBackend {
289    #[allow(clippy::too_many_lines)]
290    async fn build_image(
291        &self,
292        _context: &Path,
293        dockerfile: &Dockerfile,
294        options: &BuildOptions,
295        event_tx: Option<mpsc::Sender<BuildEvent>>,
296    ) -> Result<BuiltImage> {
297        let start_time = std::time::Instant::now();
298        let build_id = generate_build_id();
299
300        debug!(
301            "BuildahBackend: starting build (build_id: {}, {} stages)",
302            build_id,
303            dockerfile.stages.len()
304        );
305
306        // Determine stages to build.
307        let stages = self.resolve_stages(dockerfile, options.target.as_deref())?;
308        debug!("Building {} stages", stages.len());
309
310        // Build each stage.
311        let mut stage_images: HashMap<String, String> = HashMap::new();
312        // Track the final WORKDIR for each committed stage, used to resolve
313        // relative source paths in COPY --from instructions.
314        let mut stage_workdirs: HashMap<String, String> = HashMap::new();
315        let mut final_container: Option<String> = None;
316        let mut total_instructions = 0;
317
318        // Initialize the layer cache tracker for this build session.
319        let mut cache_tracker = LayerCacheTracker::new();
320
321        for (stage_idx, stage) in stages.iter().enumerate() {
322            let is_final_stage = stage_idx == stages.len() - 1;
323
324            Self::send_event(
325                event_tx.as_ref(),
326                BuildEvent::StageStarted {
327                    index: stage_idx,
328                    name: stage.name.clone(),
329                    base_image: stage.base_image.to_string_ref(),
330                },
331            );
332
333            // Create container from base image.
334            let base = self
335                .resolve_base_image(&stage.base_image, &stage_images, options)
336                .await?;
337            let container_id = self
338                .create_container(&base, options.platform.as_deref())
339                .await?;
340
341            debug!(
342                "Created container {} for stage {} (base: {})",
343                container_id,
344                stage.identifier(),
345                base
346            );
347
348            // Track the current base layer for cache key computation.
349            let mut current_base_layer = container_id.clone();
350
351            // Track the current WORKDIR for this stage.
352            let mut current_workdir = match &stage.base_image {
353                ImageRef::Stage(name) => stage_workdirs
354                    .get(name)
355                    .cloned()
356                    .unwrap_or_else(|| String::from("/")),
357                _ => String::from("/"),
358            };
359
360            // Execute instructions.
361            for (inst_idx, instruction) in stage.instructions.iter().enumerate() {
362                Self::send_event(
363                    event_tx.as_ref(),
364                    BuildEvent::InstructionStarted {
365                        stage: stage_idx,
366                        index: inst_idx,
367                        instruction: format!("{instruction:?}"),
368                    },
369                );
370
371                let instruction_cache_key = instruction.cache_key();
372                let instruction_start = std::time::Instant::now();
373
374                // Resolve COPY --from references to actual committed image names,
375                // and resolve relative source paths using the source stage's WORKDIR.
376                let resolved_instruction;
377                let instruction_ref = if let Instruction::Copy(copy) = instruction {
378                    if let Some(ref from) = copy.from {
379                        if let Some(image_name) = stage_images.get(from) {
380                            let mut resolved_copy = copy.clone();
381                            resolved_copy.from = Some(image_name.clone());
382
383                            // Resolve relative source paths using the source stage's WORKDIR.
384                            if let Some(source_workdir) = stage_workdirs.get(from) {
385                                resolved_copy.sources = resolved_copy
386                                    .sources
387                                    .iter()
388                                    .map(|src| {
389                                        if src.starts_with('/') {
390                                            src.clone()
391                                        } else if source_workdir == "/" {
392                                            format!("/{src}")
393                                        } else {
394                                            format!("{source_workdir}/{src}")
395                                        }
396                                    })
397                                    .collect();
398                            }
399
400                            resolved_instruction = Instruction::Copy(resolved_copy);
401                            &resolved_instruction
402                        } else {
403                            instruction
404                        }
405                    } else {
406                        instruction
407                    }
408                } else {
409                    instruction
410                };
411
412                // Inject default cache mounts into RUN instructions.
413                let instruction_with_defaults;
414                let instruction_ref = if options.default_cache_mounts.is_empty() {
415                    instruction_ref
416                } else if let Instruction::Run(run) = instruction_ref {
417                    let mut merged = run.clone();
418                    for default_mount in &options.default_cache_mounts {
419                        let RunMount::Cache { target, .. } = default_mount else {
420                            continue;
421                        };
422                        let already_has = merged
423                            .mounts
424                            .iter()
425                            .any(|m| matches!(m, RunMount::Cache { target: t, .. } if t == target));
426                        if !already_has {
427                            merged.mounts.push(default_mount.clone());
428                        }
429                    }
430                    instruction_with_defaults = Instruction::Run(merged);
431                    &instruction_with_defaults
432                } else {
433                    instruction_ref
434                };
435
436                let is_run_instruction = matches!(instruction_ref, Instruction::Run(_));
437                let max_attempts = if is_run_instruction {
438                    options.retries + 1
439                } else {
440                    1
441                };
442
443                let commands = BuildahCommand::from_instruction(&container_id, instruction_ref);
444
445                let mut combined_output = String::new();
446                for cmd in commands {
447                    let mut last_output = None;
448
449                    for attempt in 1..=max_attempts {
450                        if attempt > 1 {
451                            tracing::warn!(
452                                "Retrying step (attempt {}/{})...",
453                                attempt,
454                                max_attempts
455                            );
456                            Self::send_event(
457                                event_tx.as_ref(),
458                                BuildEvent::Output {
459                                    line: format!(
460                                        "⟳ Retrying step (attempt {attempt}/{max_attempts})..."
461                                    ),
462                                    is_stderr: false,
463                                },
464                            );
465                            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
466                        }
467
468                        let event_tx_clone = event_tx.clone();
469                        let output = self
470                            .executor
471                            .execute_streaming(&cmd, |is_stdout, line| {
472                                Self::send_event(
473                                    event_tx_clone.as_ref(),
474                                    BuildEvent::Output {
475                                        line: line.to_string(),
476                                        is_stderr: !is_stdout,
477                                    },
478                                );
479                            })
480                            .await?;
481
482                        combined_output.push_str(&output.stdout);
483                        combined_output.push_str(&output.stderr);
484
485                        if output.success() {
486                            last_output = Some(output);
487                            break;
488                        }
489
490                        last_output = Some(output);
491                    }
492
493                    let output = last_output.unwrap();
494                    if !output.success() {
495                        Self::send_event(
496                            event_tx.as_ref(),
497                            BuildEvent::BuildFailed {
498                                error: output.stderr.clone(),
499                            },
500                        );
501
502                        // Cleanup container.
503                        let _ = self
504                            .executor
505                            .execute(&BuildahCommand::rm(&container_id))
506                            .await;
507
508                        return Err(BuildError::buildah_execution(
509                            cmd.to_command_string(),
510                            output.exit_code,
511                            output.stderr,
512                        ));
513                    }
514                }
515
516                #[allow(clippy::cast_possible_truncation)]
517                let instruction_elapsed_ms = instruction_start.elapsed().as_millis() as u64;
518
519                // Track WORKDIR changes for later COPY --from resolution.
520                if let Instruction::Workdir(dir) = instruction {
521                    current_workdir.clone_from(dir);
522                }
523
524                // Attempt to detect if this was a cache hit.
525                let cached = cache_tracker.detect_cache_hit(
526                    instruction,
527                    instruction_elapsed_ms,
528                    &combined_output,
529                );
530
531                cache_tracker.record(
532                    instruction_cache_key.clone(),
533                    current_base_layer.clone(),
534                    cached,
535                );
536
537                current_base_layer = format!("{current_base_layer}:{instruction_cache_key}");
538
539                Self::send_event(
540                    event_tx.as_ref(),
541                    BuildEvent::InstructionComplete {
542                        stage: stage_idx,
543                        index: inst_idx,
544                        cached,
545                    },
546                );
547
548                total_instructions += 1;
549            }
550
551            // Handle stage completion.
552            if let Some(name) = &stage.name {
553                let image_name = format!("zlayer-build-{build_id}-stage-{name}");
554                self.commit_container(&container_id, &image_name, options.format.as_deref(), false)
555                    .await?;
556                stage_images.insert(name.clone(), image_name.clone());
557                stage_workdirs.insert(name.clone(), current_workdir.clone());
558
559                // Also add by index.
560                stage_images.insert(stage.index.to_string(), image_name.clone());
561                stage_workdirs.insert(stage.index.to_string(), current_workdir.clone());
562
563                if is_final_stage {
564                    final_container = Some(container_id);
565                } else {
566                    let _ = self
567                        .executor
568                        .execute(&BuildahCommand::rm(&container_id))
569                        .await;
570                }
571            } else if is_final_stage {
572                final_container = Some(container_id);
573            } else {
574                let image_name = format!("zlayer-build-{}-stage-{}", build_id, stage.index);
575                self.commit_container(&container_id, &image_name, options.format.as_deref(), false)
576                    .await?;
577                stage_images.insert(stage.index.to_string(), image_name);
578                stage_workdirs.insert(stage.index.to_string(), current_workdir.clone());
579                let _ = self
580                    .executor
581                    .execute(&BuildahCommand::rm(&container_id))
582                    .await;
583            }
584
585            Self::send_event(
586                event_tx.as_ref(),
587                BuildEvent::StageComplete { index: stage_idx },
588            );
589        }
590
591        // Commit final image.
592        let final_container = final_container.ok_or_else(|| BuildError::InvalidInstruction {
593            instruction: "build".to_string(),
594            reason: "No stages to build".to_string(),
595        })?;
596
597        let image_name = options
598            .tags
599            .first()
600            .cloned()
601            .unwrap_or_else(|| format!("zlayer-build:{}", chrono_lite_timestamp()));
602
603        let image_id = self
604            .commit_container(
605                &final_container,
606                &image_name,
607                options.format.as_deref(),
608                options.squash,
609            )
610            .await?;
611
612        info!("Committed final image: {} ({})", image_name, image_id);
613
614        // Apply additional tags.
615        for tag in options.tags.iter().skip(1) {
616            self.tag_image_internal(&image_id, tag).await?;
617            debug!("Applied tag: {}", tag);
618        }
619
620        // Cleanup.
621        let _ = self
622            .executor
623            .execute(&BuildahCommand::rm(&final_container))
624            .await;
625
626        // Cleanup intermediate stage images.
627        for (_, img) in stage_images {
628            let _ = self.executor.execute(&BuildahCommand::rmi(&img)).await;
629        }
630
631        // Push if requested.
632        if options.push {
633            for tag in &options.tags {
634                self.push_image_internal(tag, options.registry_auth.as_ref())
635                    .await?;
636                info!("Pushed image: {}", tag);
637            }
638        }
639
640        #[allow(clippy::cast_possible_truncation)]
641        let build_time_ms = start_time.elapsed().as_millis() as u64;
642
643        Self::send_event(
644            event_tx.as_ref(),
645            BuildEvent::BuildComplete {
646                image_id: image_id.clone(),
647            },
648        );
649
650        info!(
651            "Build completed in {}ms: {} with {} tags",
652            build_time_ms,
653            image_id,
654            options.tags.len()
655        );
656
657        Ok(BuiltImage {
658            image_id,
659            tags: options.tags.clone(),
660            layer_count: total_instructions,
661            size: 0, // TODO: get actual size via buildah inspect
662            build_time_ms,
663            is_manifest: false,
664        })
665    }
666
667    async fn push_image(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
668        self.push_image_internal(tag, auth).await
669    }
670
671    async fn tag_image(&self, image: &str, new_tag: &str) -> Result<()> {
672        self.tag_image_internal(image, new_tag).await
673    }
674
675    async fn manifest_create(&self, name: &str) -> Result<()> {
676        let cmd = BuildahCommand::manifest_create(name);
677        self.executor.execute_checked(&cmd).await?;
678        Ok(())
679    }
680
681    async fn manifest_add(&self, manifest: &str, image: &str) -> Result<()> {
682        let cmd = BuildahCommand::manifest_add(manifest, image);
683        self.executor.execute_checked(&cmd).await?;
684        Ok(())
685    }
686
687    async fn manifest_push(&self, name: &str, destination: &str) -> Result<()> {
688        let cmd = BuildahCommand::manifest_push(name, destination);
689        self.executor.execute_checked(&cmd).await?;
690        Ok(())
691    }
692
693    async fn is_available(&self) -> bool {
694        self.executor.is_available().await
695    }
696
697    fn name(&self) -> &'static str {
698        "buildah"
699    }
700}
701
702// ---------------------------------------------------------------------------
703// Helpers
704// ---------------------------------------------------------------------------
705
706fn chrono_lite_timestamp() -> String {
707    use std::time::{SystemTime, UNIX_EPOCH};
708    let duration = SystemTime::now()
709        .duration_since(UNIX_EPOCH)
710        .unwrap_or_default();
711    format!("{}", duration.as_secs())
712}
713
714/// Generate a short unique build ID for namespacing intermediate stage images.
715fn generate_build_id() -> String {
716    use sha2::{Digest, Sha256};
717    use std::time::{SystemTime, UNIX_EPOCH};
718
719    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
720
721    let nanos = SystemTime::now()
722        .duration_since(UNIX_EPOCH)
723        .unwrap_or_default()
724        .as_nanos();
725    let pid = std::process::id();
726    let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
727
728    let mut hasher = Sha256::new();
729    hasher.update(nanos.to_le_bytes());
730    hasher.update(pid.to_le_bytes());
731    hasher.update(count.to_le_bytes());
732    let hash = hasher.finalize();
733    hex::encode(&hash[..6])
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    #[test]
741    fn test_layer_cache_tracker_new() {
742        let tracker = LayerCacheTracker::new();
743        assert!(tracker.known_layers.is_empty());
744    }
745
746    #[test]
747    fn test_layer_cache_tracker_record_and_lookup() {
748        let mut tracker = LayerCacheTracker::new();
749
750        tracker.record("abc123".to_string(), "container-1".to_string(), false);
751        assert!(!tracker.is_cached("abc123", "container-1"));
752
753        tracker.record("def456".to_string(), "container-2".to_string(), true);
754        assert!(tracker.is_cached("def456", "container-2"));
755    }
756
757    #[test]
758    fn test_layer_cache_tracker_unknown_returns_false() {
759        let tracker = LayerCacheTracker::new();
760        assert!(!tracker.is_cached("unknown", "unknown"));
761    }
762
763    #[test]
764    fn test_layer_cache_tracker_different_base_layers() {
765        let mut tracker = LayerCacheTracker::new();
766
767        tracker.record("inst-1".to_string(), "base-a".to_string(), true);
768        tracker.record("inst-1".to_string(), "base-b".to_string(), false);
769
770        assert!(tracker.is_cached("inst-1", "base-a"));
771        assert!(!tracker.is_cached("inst-1", "base-b"));
772    }
773
774    #[test]
775    fn test_layer_cache_tracker_detect_cache_hit() {
776        use crate::dockerfile::RunInstruction;
777
778        let tracker = LayerCacheTracker::new();
779        let instruction = Instruction::Run(RunInstruction::shell("echo hello"));
780
781        assert!(!tracker.detect_cache_hit(&instruction, 50, ""));
782        assert!(!tracker.detect_cache_hit(&instruction, 1000, ""));
783        assert!(!tracker.detect_cache_hit(&instruction, 50, "Using cache"));
784    }
785
786    #[test]
787    fn test_layer_cache_tracker_overwrite() {
788        let mut tracker = LayerCacheTracker::new();
789
790        tracker.record("key".to_string(), "base".to_string(), false);
791        assert!(!tracker.is_cached("key", "base"));
792
793        tracker.record("key".to_string(), "base".to_string(), true);
794        assert!(tracker.is_cached("key", "base"));
795    }
796}