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, PullBaseMode, 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(
240        &self,
241        image: &str,
242        platform: Option<&str>,
243        pull: PullBaseMode,
244    ) -> Result<String> {
245        let mut cmd = BuildahCommand::new("from").arg_opt("--platform", platform);
246
247        match pull {
248            PullBaseMode::Newer => cmd = cmd.arg("--pull=newer"),
249            PullBaseMode::Always => cmd = cmd.arg("--pull=always"),
250            PullBaseMode::Never => { /* no flag — let buildah use whatever is local */ }
251        }
252
253        cmd = cmd.arg(image);
254
255        let output = self.executor.execute_checked(&cmd).await?;
256        Ok(output.stdout.trim().to_string())
257    }
258
259    /// Commit a container to create an image.
260    async fn commit_container(
261        &self,
262        container: &str,
263        image_name: &str,
264        format: Option<&str>,
265        squash: bool,
266    ) -> Result<String> {
267        let cmd = BuildahCommand::commit_with_opts(container, image_name, format, squash);
268        let output = self.executor.execute_checked(&cmd).await?;
269        Ok(output.stdout.trim().to_string())
270    }
271
272    /// Tag an image with an additional tag.
273    async fn tag_image_internal(&self, image: &str, tag: &str) -> Result<()> {
274        let cmd = BuildahCommand::tag(image, tag);
275        self.executor.execute_checked(&cmd).await?;
276        Ok(())
277    }
278
279    /// Push an image to a registry.
280    async fn push_image_internal(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
281        let mut cmd = BuildahCommand::push(tag);
282        if let Some(auth) = auth {
283            cmd = cmd
284                .arg("--creds")
285                .arg(format!("{}:{}", auth.username, auth.password));
286        }
287        self.executor.execute_checked(&cmd).await?;
288        Ok(())
289    }
290
291    /// Send an event to the TUI (if configured).
292    fn send_event(event_tx: Option<&mpsc::Sender<BuildEvent>>, event: BuildEvent) {
293        if let Some(tx) = event_tx {
294            let _ = tx.send(event);
295        }
296    }
297}
298
299#[async_trait::async_trait]
300impl BuildBackend for BuildahBackend {
301    #[allow(clippy::too_many_lines)]
302    async fn build_image(
303        &self,
304        _context: &Path,
305        dockerfile: &Dockerfile,
306        options: &BuildOptions,
307        event_tx: Option<mpsc::Sender<BuildEvent>>,
308    ) -> Result<BuiltImage> {
309        let start_time = std::time::Instant::now();
310        let build_id = generate_build_id();
311
312        debug!(
313            "BuildahBackend: starting build (build_id: {}, {} stages)",
314            build_id,
315            dockerfile.stages.len()
316        );
317
318        // Determine stages to build.
319        let stages = self.resolve_stages(dockerfile, options.target.as_deref())?;
320        debug!("Building {} stages", stages.len());
321
322        // Build each stage.
323        let mut stage_images: HashMap<String, String> = HashMap::new();
324        // Track the final WORKDIR for each committed stage, used to resolve
325        // relative source paths in COPY --from instructions.
326        let mut stage_workdirs: HashMap<String, String> = HashMap::new();
327        let mut final_container: Option<String> = None;
328        let mut total_instructions = 0;
329
330        // Initialize the layer cache tracker for this build session.
331        let mut cache_tracker = LayerCacheTracker::new();
332
333        for (stage_idx, stage) in stages.iter().enumerate() {
334            let is_final_stage = stage_idx == stages.len() - 1;
335
336            Self::send_event(
337                event_tx.as_ref(),
338                BuildEvent::StageStarted {
339                    index: stage_idx,
340                    name: stage.name.clone(),
341                    base_image: stage.base_image.to_string_ref(),
342                },
343            );
344
345            // Create container from base image.
346            let base = self
347                .resolve_base_image(&stage.base_image, &stage_images, options)
348                .await?;
349            let container_id = self
350                .create_container(&base, options.platform.as_deref(), options.pull)
351                .await?;
352
353            debug!(
354                "Created container {} for stage {} (base: {})",
355                container_id,
356                stage.identifier(),
357                base
358            );
359
360            // Track the current base layer for cache key computation.
361            let mut current_base_layer = container_id.clone();
362
363            // Track the current WORKDIR for this stage.
364            let mut current_workdir = match &stage.base_image {
365                ImageRef::Stage(name) => stage_workdirs
366                    .get(name)
367                    .cloned()
368                    .unwrap_or_else(|| String::from("/")),
369                _ => String::from("/"),
370            };
371
372            // Execute instructions.
373            for (inst_idx, instruction) in stage.instructions.iter().enumerate() {
374                Self::send_event(
375                    event_tx.as_ref(),
376                    BuildEvent::InstructionStarted {
377                        stage: stage_idx,
378                        index: inst_idx,
379                        instruction: format!("{instruction:?}"),
380                    },
381                );
382
383                let instruction_cache_key = instruction.cache_key();
384                let instruction_start = std::time::Instant::now();
385
386                // Resolve COPY --from references to actual committed image names,
387                // and resolve relative source paths using the source stage's WORKDIR.
388                let resolved_instruction;
389                let instruction_ref = if let Instruction::Copy(copy) = instruction {
390                    if let Some(ref from) = copy.from {
391                        if let Some(image_name) = stage_images.get(from) {
392                            let mut resolved_copy = copy.clone();
393                            resolved_copy.from = Some(image_name.clone());
394
395                            // Resolve relative source paths using the source stage's WORKDIR.
396                            if let Some(source_workdir) = stage_workdirs.get(from) {
397                                resolved_copy.sources = resolved_copy
398                                    .sources
399                                    .iter()
400                                    .map(|src| {
401                                        if src.starts_with('/') {
402                                            src.clone()
403                                        } else if source_workdir == "/" {
404                                            format!("/{src}")
405                                        } else {
406                                            format!("{source_workdir}/{src}")
407                                        }
408                                    })
409                                    .collect();
410                            }
411
412                            resolved_instruction = Instruction::Copy(resolved_copy);
413                            &resolved_instruction
414                        } else {
415                            instruction
416                        }
417                    } else {
418                        instruction
419                    }
420                } else {
421                    instruction
422                };
423
424                // Inject default cache mounts into RUN instructions.
425                let instruction_with_defaults;
426                let instruction_ref = if options.default_cache_mounts.is_empty() {
427                    instruction_ref
428                } else if let Instruction::Run(run) = instruction_ref {
429                    let mut merged = run.clone();
430                    for default_mount in &options.default_cache_mounts {
431                        let RunMount::Cache { target, .. } = default_mount else {
432                            continue;
433                        };
434                        let already_has = merged
435                            .mounts
436                            .iter()
437                            .any(|m| matches!(m, RunMount::Cache { target: t, .. } if t == target));
438                        if !already_has {
439                            merged.mounts.push(default_mount.clone());
440                        }
441                    }
442                    instruction_with_defaults = Instruction::Run(merged);
443                    &instruction_with_defaults
444                } else {
445                    instruction_ref
446                };
447
448                let is_run_instruction = matches!(instruction_ref, Instruction::Run(_));
449                let max_attempts = if is_run_instruction {
450                    options.retries + 1
451                } else {
452                    1
453                };
454
455                let commands = BuildahCommand::from_instruction(&container_id, instruction_ref);
456
457                let mut combined_output = String::new();
458                for cmd in commands {
459                    let mut last_output = None;
460
461                    for attempt in 1..=max_attempts {
462                        if attempt > 1 {
463                            tracing::warn!(
464                                "Retrying step (attempt {}/{})...",
465                                attempt,
466                                max_attempts
467                            );
468                            Self::send_event(
469                                event_tx.as_ref(),
470                                BuildEvent::Output {
471                                    line: format!(
472                                        "⟳ Retrying step (attempt {attempt}/{max_attempts})..."
473                                    ),
474                                    is_stderr: false,
475                                },
476                            );
477                            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
478                        }
479
480                        let event_tx_clone = event_tx.clone();
481                        let output = self
482                            .executor
483                            .execute_streaming(&cmd, |is_stdout, line| {
484                                Self::send_event(
485                                    event_tx_clone.as_ref(),
486                                    BuildEvent::Output {
487                                        line: line.to_string(),
488                                        is_stderr: !is_stdout,
489                                    },
490                                );
491                            })
492                            .await?;
493
494                        combined_output.push_str(&output.stdout);
495                        combined_output.push_str(&output.stderr);
496
497                        if output.success() {
498                            last_output = Some(output);
499                            break;
500                        }
501
502                        last_output = Some(output);
503                    }
504
505                    let output = last_output.unwrap();
506                    if !output.success() {
507                        Self::send_event(
508                            event_tx.as_ref(),
509                            BuildEvent::BuildFailed {
510                                error: output.stderr.clone(),
511                            },
512                        );
513
514                        // Cleanup container.
515                        let _ = self
516                            .executor
517                            .execute(&BuildahCommand::rm(&container_id))
518                            .await;
519
520                        return Err(BuildError::buildah_execution(
521                            cmd.to_command_string(),
522                            output.exit_code,
523                            output.stderr,
524                        ));
525                    }
526                }
527
528                #[allow(clippy::cast_possible_truncation)]
529                let instruction_elapsed_ms = instruction_start.elapsed().as_millis() as u64;
530
531                // Track WORKDIR changes for later COPY --from resolution.
532                if let Instruction::Workdir(dir) = instruction {
533                    current_workdir.clone_from(dir);
534                }
535
536                // Attempt to detect if this was a cache hit.
537                let cached = cache_tracker.detect_cache_hit(
538                    instruction,
539                    instruction_elapsed_ms,
540                    &combined_output,
541                );
542
543                cache_tracker.record(
544                    instruction_cache_key.clone(),
545                    current_base_layer.clone(),
546                    cached,
547                );
548
549                current_base_layer = format!("{current_base_layer}:{instruction_cache_key}");
550
551                Self::send_event(
552                    event_tx.as_ref(),
553                    BuildEvent::InstructionComplete {
554                        stage: stage_idx,
555                        index: inst_idx,
556                        cached,
557                    },
558                );
559
560                total_instructions += 1;
561            }
562
563            // Handle stage completion.
564            if let Some(name) = &stage.name {
565                let image_name = format!("zlayer-build-{build_id}-stage-{name}");
566                self.commit_container(&container_id, &image_name, options.format.as_deref(), false)
567                    .await?;
568                stage_images.insert(name.clone(), image_name.clone());
569                stage_workdirs.insert(name.clone(), current_workdir.clone());
570
571                // Also add by index.
572                stage_images.insert(stage.index.to_string(), image_name.clone());
573                stage_workdirs.insert(stage.index.to_string(), current_workdir.clone());
574
575                if is_final_stage {
576                    final_container = Some(container_id);
577                } else {
578                    let _ = self
579                        .executor
580                        .execute(&BuildahCommand::rm(&container_id))
581                        .await;
582                }
583            } else if is_final_stage {
584                final_container = Some(container_id);
585            } else {
586                let image_name = format!("zlayer-build-{}-stage-{}", build_id, stage.index);
587                self.commit_container(&container_id, &image_name, options.format.as_deref(), false)
588                    .await?;
589                stage_images.insert(stage.index.to_string(), image_name);
590                stage_workdirs.insert(stage.index.to_string(), current_workdir.clone());
591                let _ = self
592                    .executor
593                    .execute(&BuildahCommand::rm(&container_id))
594                    .await;
595            }
596
597            Self::send_event(
598                event_tx.as_ref(),
599                BuildEvent::StageComplete { index: stage_idx },
600            );
601        }
602
603        // Commit final image.
604        let final_container = final_container.ok_or_else(|| BuildError::InvalidInstruction {
605            instruction: "build".to_string(),
606            reason: "No stages to build".to_string(),
607        })?;
608
609        let image_name = options
610            .tags
611            .first()
612            .cloned()
613            .unwrap_or_else(|| format!("zlayer-build:{}", chrono_lite_timestamp()));
614
615        let image_id = self
616            .commit_container(
617                &final_container,
618                &image_name,
619                options.format.as_deref(),
620                options.squash,
621            )
622            .await?;
623
624        info!("Committed final image: {} ({})", image_name, image_id);
625
626        // Apply additional tags.
627        for tag in options.tags.iter().skip(1) {
628            self.tag_image_internal(&image_id, tag).await?;
629            debug!("Applied tag: {}", tag);
630        }
631
632        // Cleanup.
633        let _ = self
634            .executor
635            .execute(&BuildahCommand::rm(&final_container))
636            .await;
637
638        // Cleanup intermediate stage images.
639        for (_, img) in stage_images {
640            let _ = self.executor.execute(&BuildahCommand::rmi(&img)).await;
641        }
642
643        // Push if requested.
644        if options.push {
645            for tag in &options.tags {
646                self.push_image_internal(tag, options.registry_auth.as_ref())
647                    .await?;
648                info!("Pushed image: {}", tag);
649            }
650        }
651
652        #[allow(clippy::cast_possible_truncation)]
653        let build_time_ms = start_time.elapsed().as_millis() as u64;
654
655        Self::send_event(
656            event_tx.as_ref(),
657            BuildEvent::BuildComplete {
658                image_id: image_id.clone(),
659            },
660        );
661
662        info!(
663            "Build completed in {}ms: {} with {} tags",
664            build_time_ms,
665            image_id,
666            options.tags.len()
667        );
668
669        Ok(BuiltImage {
670            image_id,
671            tags: options.tags.clone(),
672            layer_count: total_instructions,
673            size: 0, // TODO: get actual size via buildah inspect
674            build_time_ms,
675            is_manifest: false,
676        })
677    }
678
679    async fn push_image(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()> {
680        self.push_image_internal(tag, auth).await
681    }
682
683    async fn tag_image(&self, image: &str, new_tag: &str) -> Result<()> {
684        self.tag_image_internal(image, new_tag).await
685    }
686
687    async fn manifest_create(&self, name: &str) -> Result<()> {
688        let cmd = BuildahCommand::manifest_create(name);
689        self.executor.execute_checked(&cmd).await?;
690        Ok(())
691    }
692
693    async fn manifest_add(&self, manifest: &str, image: &str) -> Result<()> {
694        let cmd = BuildahCommand::manifest_add(manifest, image);
695        self.executor.execute_checked(&cmd).await?;
696        Ok(())
697    }
698
699    async fn manifest_push(&self, name: &str, destination: &str) -> Result<()> {
700        let cmd = BuildahCommand::manifest_push(name, destination);
701        self.executor.execute_checked(&cmd).await?;
702        Ok(())
703    }
704
705    async fn is_available(&self) -> bool {
706        self.executor.is_available().await
707    }
708
709    fn name(&self) -> &'static str {
710        "buildah"
711    }
712}
713
714// ---------------------------------------------------------------------------
715// Helpers
716// ---------------------------------------------------------------------------
717
718fn chrono_lite_timestamp() -> String {
719    use std::time::{SystemTime, UNIX_EPOCH};
720    let duration = SystemTime::now()
721        .duration_since(UNIX_EPOCH)
722        .unwrap_or_default();
723    format!("{}", duration.as_secs())
724}
725
726/// Generate a short unique build ID for namespacing intermediate stage images.
727fn generate_build_id() -> String {
728    use sha2::{Digest, Sha256};
729    use std::time::{SystemTime, UNIX_EPOCH};
730
731    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
732
733    let nanos = SystemTime::now()
734        .duration_since(UNIX_EPOCH)
735        .unwrap_or_default()
736        .as_nanos();
737    let pid = std::process::id();
738    let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
739
740    let mut hasher = Sha256::new();
741    hasher.update(nanos.to_le_bytes());
742    hasher.update(pid.to_le_bytes());
743    hasher.update(count.to_le_bytes());
744    let hash = hasher.finalize();
745    hex::encode(&hash[..6])
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    #[test]
753    fn test_layer_cache_tracker_new() {
754        let tracker = LayerCacheTracker::new();
755        assert!(tracker.known_layers.is_empty());
756    }
757
758    #[test]
759    fn test_layer_cache_tracker_record_and_lookup() {
760        let mut tracker = LayerCacheTracker::new();
761
762        tracker.record("abc123".to_string(), "container-1".to_string(), false);
763        assert!(!tracker.is_cached("abc123", "container-1"));
764
765        tracker.record("def456".to_string(), "container-2".to_string(), true);
766        assert!(tracker.is_cached("def456", "container-2"));
767    }
768
769    #[test]
770    fn test_layer_cache_tracker_unknown_returns_false() {
771        let tracker = LayerCacheTracker::new();
772        assert!(!tracker.is_cached("unknown", "unknown"));
773    }
774
775    #[test]
776    fn test_layer_cache_tracker_different_base_layers() {
777        let mut tracker = LayerCacheTracker::new();
778
779        tracker.record("inst-1".to_string(), "base-a".to_string(), true);
780        tracker.record("inst-1".to_string(), "base-b".to_string(), false);
781
782        assert!(tracker.is_cached("inst-1", "base-a"));
783        assert!(!tracker.is_cached("inst-1", "base-b"));
784    }
785
786    #[test]
787    fn test_layer_cache_tracker_detect_cache_hit() {
788        use crate::dockerfile::RunInstruction;
789
790        let tracker = LayerCacheTracker::new();
791        let instruction = Instruction::Run(RunInstruction::shell("echo hello"));
792
793        assert!(!tracker.detect_cache_hit(&instruction, 50, ""));
794        assert!(!tracker.detect_cache_hit(&instruction, 1000, ""));
795        assert!(!tracker.detect_cache_hit(&instruction, 50, "Using cache"));
796    }
797
798    #[test]
799    fn test_layer_cache_tracker_overwrite() {
800        let mut tracker = LayerCacheTracker::new();
801
802        tracker.record("key".to_string(), "base".to_string(), false);
803        assert!(!tracker.is_cached("key", "base"));
804
805        tracker.record("key".to_string(), "base".to_string(), true);
806        assert!(tracker.is_cached("key", "base"));
807    }
808}