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