Repository: railwayapp/nixpacks
Pull request #161 (2022-06-02 00:21:36)

Decouple plan generation from image creation
A fairly significant refactor that decouples the generation of a build plan from creating an image. Both are defined via traits. The image creation is setup such that if we wanted to use [something other than docker](https://github.com/railwayapp/nixpacks/discussions/121), we could.

The plan generator takes an app and environment and produces a build plan.

```rust
pub trait PlanGenerator {
    fn generate_plan(&mut self, app: &App, environment: &Environment) -> Result<BuildPlan>;
}
```

A builder takes a build plan and creates an image

```rust
pub trait Builder {
    fn create_image(&self, app_source: &str, plan: &BuildPlan) -> Result<()>;
}
```

The main library functions only care about these traits and the code is quite a bit cleaner.

I've also cleaned up the generate plan tests with a little helper.

_Note: I think this helps quite a bit but very open to suggestions on other improvements we could make_.


Patch:
diff --git a/src/lib.rs b/src/lib.rs
index aae143e..cbc5630 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,20 +1,25 @@
-use std::env;
-
-use crate::{
-    nixpacks::{
-        app::App, environment::Environment, logger::Logger, nix::Pkg, plan::BuildPlan, AppBuilder,
-        AppBuilderOptions,
+use crate::nixpacks::{
+    app::App,
+    builder::{
+        docker::{DockerBuilder, DockerBuilderOptions},
+        Builder,
     },
-    providers::{
-        crystal::CrystalProvider, deno::DenoProvider, go::GolangProvider,
-        haskell::HaskellStackProvider, node::NodeProvider, python::PythonProvider,
-        rust::RustProvider,
+    environment::Environment,
+    logger::Logger,
+    nix::pkg::Pkg,
+    plan::{
+        generator::{GeneratePlanOptions, NixpacksBuildPlanGenerator},
+        BuildPlan, PlanGenerator,
     },
 };
-use anyhow::{bail, Result};
-use providers::Provider;
+use anyhow::Result;
+use providers::{
+    crystal::CrystalProvider, deno::DenoProvider, go::GolangProvider,
+    haskell::HaskellStackProvider, node::NodeProvider, python::PythonProvider, rust::RustProvider,
+    Provider,
+};
 
-pub(crate) mod chain;
+mod chain;
 pub mod nixpacks;
 pub mod providers;
 
@@ -30,119 +35,35 @@ pub fn get_providers() -> Vec<&'static dyn Provider> {
     ]
 }
 
-pub fn gen_plan(
+pub fn generate_build_plan(
     path: &str,
-    custom_pkgs: Vec<&str>,
-    custom_build_cmd: Option<String>,
-    custom_start_cmd: Option<String>,
     envs: Vec<&str>,
-    pin_pkgs: bool,
+    plan_options: &GeneratePlanOptions,
 ) -> Result<BuildPlan> {
-    let logger = Logger::new();
-    let providers = get_providers();
-
-    let options = AppBuilderOptions {
-        custom_pkgs: custom_pkgs.iter().map(|p| Pkg::new(p)).collect(),
-        custom_build_cmd,
-        custom_start_cmd,
-        pin_pkgs,
-        out_dir: None,
-        plan_path: None,
-        tags: Vec::new(),
-        labels: Vec::new(),
-        quiet: false,
-    };
-
     let app = App::new(path)?;
-    let environment = create_environment(envs)?;
-    let mut app_builder = AppBuilder::new(None, &app, &environment, &logger, &options)?;
+    let environment = Environment::from_envs(envs)?;
+
+    let mut generator = NixpacksBuildPlanGenerator::new(get_providers(), plan_options.to_owned());
+    let plan = generator.generate_plan(&app, &environment)?;
 
-    let plan = app_builder.plan(providers)?;
     Ok(plan)
 }
 
-#[allow(clippy::too_many_arguments)]
-pub fn build(
+pub fn create_docker_image(
     path: &str,
-    name: Option<String>,
-    custom_pkgs: Vec<&str>,
-    custom_build_cmd: Option<String>,
-    custom_start_cmd: Option<String>,
-    pin_pkgs: bool,
     envs: Vec<&str>,
-    plan_path: Option<String>,
-    out_dir: Option<String>,
-    tags: Vec<&str>,
-    labels: Vec<&str>,
-    quiet: bool,
+    plan_options: &GeneratePlanOptions,
+    build_options: &DockerBuilderOptions,
 ) -> Result<()> {
-    let logger = Logger::new();
-    let providers = get_providers();
-
-    let options = AppBuilderOptions {
-        custom_pkgs: custom_pkgs.iter().map(|p| Pkg::new(p)).collect(),
-        custom_build_cmd,
-        custom_start_cmd,
-        pin_pkgs,
-        out_dir,
-        plan_path,
-        tags: tags.iter().map(|s| s.to_string()).collect(),
-        labels: labels.iter().map(|s| s.to_string()).collect(),
-        quiet,
-    };
-
     let app = App::new(path)?;
-    let environment = create_environment(envs)?;
-    let mut app_builder = AppBuilder::new(name, &app, &environment, &logger, &options)?;
+    let environment = Environment::from_envs(envs)?;
 
-    app_builder.build(providers)?;
+    let mut generator = NixpacksBuildPlanGenerator::new(get_providers(), plan_options.to_owned());
+    let plan = generator.generate_plan(&app, &environment)?;
 
-    Ok(())
-}
-
-pub fn create_environment(envs: Vec<&str>) -> Result<Environment> {
-    let mut environment = Environment::default();
-    for env in envs {
-        let v: Vec<&str> = env.split('=').collect();
-        if v.len() == 1 {
-            // Pull the variable from the current environment
-            let name = v[0];
-            if let Ok(value) = env::var(name) {
-                // Variable is set
-                environment.set_variable(name.to_string(), value);
-            }
-        } else if v.len() > 2 {
-            bail!("Unable to parse variable string");
-        } else {
-            // Use provided name, value pair
-            environment.set_variable(v[0].to_string(), v[1].to_string());
-        }
-    }
-
-    Ok(environment)
-}
-
-#[cfg(test)]
-mod tests {
-    use crate::create_environment;
-
-    #[test]
-    fn test_environment_variable_parsing() {
-        let environment =
-            create_environment(vec!["HELLO=world", "CARGO_PKG_NAME", "NON_EXISTANT"]).unwrap();
-        assert_eq!(
-            environment.get_variable("HELLO"),
-            Some(&"world".to_string())
-        );
-        assert_eq!(
-            environment.get_variable("CARGO_PKG_NAME"),
-            Some(&"nixpacks".to_string())
-        );
-        assert!(environment.get_variable("NON_EXISTANT").is_none());
-    }
+    let logger = Logger::new();
+    let builder = DockerBuilder::new(logger, build_options.to_owned());
+    builder.create_image(app.source.to_str().unwrap(), &plan)?;
 
-    #[test]
-    fn test_create_invalid_environment() {
-        assert!(create_environment(vec!["INVALID=ENV=CONFIG"]).is_err());
-    }
+    Ok(())
 }
diff --git a/src/main.rs b/src/main.rs
index 9b9f7c5..c520b18 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,11 @@
-use ::nixpacks::{build, gen_plan};
 use anyhow::Result;
 use clap::{arg, Arg, Command};
+use nixpacks::{
+    create_docker_image, generate_build_plan,
+    nixpacks::{
+        builder::docker::DockerBuilderOptions, nix::pkg::Pkg, plan::generator::GeneratePlanOptions,
+    },
+};
 
 fn main() -> Result<()> {
     const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -25,12 +30,6 @@ fn main() -> Result<()> {
                         .help("Name for the built image")
                         .takes_value(true),
                 )
-                .arg(
-                    Arg::new("plan")
-                        .long("plan")
-                        .help("Existing build plan file to use")
-                        .takes_value(true),
-                )
                 .arg(
                     Arg::new("out")
                         .long("out")
@@ -55,6 +54,13 @@ fn main() -> Result<()> {
                         .multiple_values(true),
                 ),
         )
+        .arg(
+            Arg::new("plan")
+                .long("plan")
+                .help("Existing build plan file to use")
+                .takes_value(true)
+                .global(true),
+        )
         .arg(
             Arg::new("build_cmd")
                 .long("build-cmd")
@@ -99,8 +105,8 @@ fn main() -> Result<()> {
 
     let build_cmd = matches.value_of("build_cmd").map(|s| s.to_string());
     let start_cmd = matches.value_of("start_cmd").map(|s| s.to_string());
-    let pkgs: Vec<_> = match matches.values_of("pkgs") {
-        Some(values) => values.collect(),
+    let pkgs = match matches.values_of("pkgs") {
+        Some(values) => values.map(Pkg::new).collect::<Vec<_>>(),
         None => Vec::new(),
     };
     let pin_pkgs = matches.is_present("pin");
@@ -110,34 +116,48 @@ fn main() -> Result<()> {
         None => Vec::new(),
     };
 
+    let plan_path = matches.value_of("plan").map(|n| n.to_string());
+
+    let plan_options = &GeneratePlanOptions {
+        custom_start_cmd: start_cmd,
+        custom_build_cmd: build_cmd,
+        custom_pkgs: pkgs,
+        pin_pkgs,
+        plan_path,
+    };
+
     match &matches.subcommand() {
         Some(("plan", matches)) => {
             let path = matches.value_of("PATH").expect("required");
 
-            let plan = gen_plan(path, pkgs, build_cmd, start_cmd, envs, pin_pkgs)?;
+            let plan = generate_build_plan(path, envs, plan_options)?;
             let json = serde_json::to_string_pretty(&plan)?;
             println!("{}", json);
         }
         Some(("build", matches)) => {
             let path = matches.value_of("PATH").expect("required");
             let name = matches.value_of("name").map(|n| n.to_string());
-            let plan_path = matches.value_of("plan").map(|n| n.to_string());
-            let output_dir = matches.value_of("out").map(|n| n.to_string());
+            let out_dir = matches.value_of("out").map(|n| n.to_string());
 
             let tags = matches
                 .values_of("tag")
-                .map(|values| values.collect())
+                .map(|values| values.map(|s| s.to_string()).collect::<Vec<_>>())
                 .unwrap_or_default();
 
             let labels = matches
                 .values_of("label")
-                .map(|values| values.collect())
+                .map(|values| values.map(|s| s.to_string()).collect::<Vec<_>>())
                 .unwrap_or_default();
 
-            build(
-                path, name, pkgs, build_cmd, start_cmd, pin_pkgs, envs, plan_path, output_dir,
-                tags, labels, false,
-            )?;
+            let build_options = &DockerBuilderOptions {
+                name,
+                tags,
+                labels,
+                out_dir,
+                quiet: false,
+            };
+
+            create_docker_image(path, envs, plan_options, build_options)?;
         }
         _ => eprintln!("Invalid command"),
     }
diff --git a/src/nixpacks/app.rs b/src/nixpacks/app.rs
index 2df7a59..f239c4a 100644
--- a/src/nixpacks/app.rs
+++ b/src/nixpacks/app.rs
@@ -31,6 +31,7 @@ impl App {
 
         Ok(App { source, paths })
     }
+
     /// Check if a file exists
     pub fn includes_file(&self, name: &str) -> bool {
         fs::canonicalize(self.source.join(name)).is_ok()
diff --git a/src/nixpacks/builder/docker.rs b/src/nixpacks/builder/docker.rs
new file mode 100644
index 0000000..bb3eb93
--- /dev/null
+++ b/src/nixpacks/builder/docker.rs
@@ -0,0 +1,333 @@
+use std::{
+    fs::{self, File},
+    io::Write,
+    path::{Path, PathBuf},
+    process::Command,
+};
+
+use super::Builder;
+use crate::nixpacks::{app, files, logger::Logger, nix, plan::BuildPlan, NIX_PACKS_VERSION};
+use anyhow::{bail, Context, Ok, Result};
+use indoc::formatdoc;
+use tempdir::TempDir;
+use uuid::Uuid;
+
+#[derive(Clone, Default, Debug)]
+pub struct DockerBuilderOptions {
+    pub name: Option<String>,
+    pub out_dir: Option<String>,
+    pub tags: Vec<String>,
+    pub labels: Vec<String>,
+    pub quiet: bool,
+}
+
+pub struct DockerBuilder {
+    logger: Logger,
+    options: DockerBuilderOptions,
+}
+
+impl Builder for DockerBuilder {
+    fn create_image(&self, app_src: &str, plan: &BuildPlan) -> Result<()> {
+        self.logger
+            .log_section(format!("Building (nixpacks v{})", NIX_PACKS_VERSION).as_str());
+
+        println!("{}", plan.get_build_string());
+
+        let id = Uuid::new_v4();
+
+        let dir = match &self.options.out_dir {
+            Some(dir) => dir.into(),
+            None => {
+                let tmp = TempDir::new("nixpacks").context("Creating a temp directory")?;
+                tmp.into_path()
+            }
+        };
+        let dest = dir.to_str().context("Invalid temp directory path")?;
+        let name = self.options.name.clone().unwrap_or_else(|| id.to_string());
+
+        // Write everything to destination
+        self.write_app(app_src, dest).context("Writing app")?;
+        self.write_assets(plan, dest).context("Writing assets")?;
+        self.write_dockerfile(plan, dest)
+            .context("Writing Dockerfile")?;
+        self.write_nix_expression(plan, dest)
+            .context("Writing NIx expression")?;
+
+        // Only build if the --out flag was not specified
+        if self.options.out_dir.is_none() {
+            let mut docker_build_cmd = self.get_docker_build_cmd(plan, name.as_str(), dest);
+
+            // Execute docker build
+            let build_result = docker_build_cmd.spawn()?.wait().context("Building image")?;
+
+            if !build_result.success() {
+                bail!("Docker build failed")
+            }
+
+            self.logger.log_section("Successfully Built!");
+
+            println!("\nRun:");
+            println!("  docker run -it {}", name);
+        } else {
+            println!("\nSaved output to:");
+            println!("  {}", dest);
+        }
+
+        Ok(())
+    }
+}
+
+impl DockerBuilder {
+    pub fn new(logger: Logger, options: DockerBuilderOptions) -> DockerBuilder {
+        DockerBuilder { logger, options }
+    }
+
+    fn get_docker_build_cmd(&self, plan: &BuildPlan, name: &str, dest: &str) -> Command {
+        let mut docker_build_cmd = Command::new("docker");
+        docker_build_cmd.arg("build").arg(dest).arg("-t").arg(name);
+
+        if self.options.quiet {
+            docker_build_cmd.arg("--quiet");
+        }
+
+        // Add build environment variables
+        for (name, value) in plan.variables.clone().unwrap_or_default().iter() {
+            docker_build_cmd
+                .arg("--build-arg")
+                .arg(format!("{}={}", name, value));
+        }
+
+        // Add user defined tags and labels to the image
+        for t in self.options.tags.clone() {
+            docker_build_cmd.arg("-t").arg(t);
+        }
+        for l in self.options.labels.clone() {
+            docker_build_cmd.arg("--label").arg(l);
+        }
+
+        docker_build_cmd
+    }
+
+    fn write_app(&self, app_src: &str, dest: &str) -> Result<()> {
+        files::recursive_copy_dir(app_src, &dest)
+    }
+
+    fn write_dockerfile(&self, plan: &BuildPlan, dest: &str) -> Result<()> {
+        let dockerfile = self.create_dockerfile(plan);
+
+        let dockerfile_path = PathBuf::from(dest).join(PathBuf::from("Dockerfile"));
+        File::create(dockerfile_path.clone()).context("Creating Dockerfile file")?;
+        fs::write(dockerfile_path, dockerfile).context("Writing Dockerfile")?;
+
+        Ok(())
+    }
+
+    fn write_nix_expression(&self, plan: &BuildPlan, dest: &str) -> Result<()> {
+        let nix_expression = nix::create_nix_expression(plan);
+
+        let nix_path = PathBuf::from(dest).join(PathBuf::from("environment.nix"));
+        let mut nix_file = File::create(nix_path).context("Creating Nix environment file")?;
+        nix_file
+            .write_all(nix_expression.as_bytes())
+            .context("Unable to write Nix expression")?;
+
+        Ok(())
+    }
+
+    fn write_assets(&self, plan: &BuildPlan, dest: &str) -> Result<()> {
+        if let Some(assets) = &plan.static_assets {
+            if !assets.is_empty() {
+                let static_assets_path = PathBuf::from(dest).join(PathBuf::from("assets"));
+                fs::create_dir_all(&static_assets_path).context("Creating static assets folder")?;
+
+                for (name, content) in assets {
+                    let path = Path::new(&static_assets_path).join(name);
+                    let parent = path.parent().unwrap();
+                    fs::create_dir_all(parent)
+                        .context(format!("Creating parent directory for {}", name))?;
+                    let mut file =
+                        File::create(path).context(format!("Creating asset file for {name}"))?;
+                    file.write_all(content.as_bytes())
+                        .context(format!("Writing asset {name}"))?;
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    fn create_dockerfile(&self, plan: &BuildPlan) -> String {
+        let app_dir = "/app/";
+        let assets_dir = app::ASSETS_DIR;
+
+        let setup_phase = plan.setup.clone().unwrap_or_default();
+        let install_phase = plan.install.clone().unwrap_or_default();
+        let build_phase = plan.build.clone().unwrap_or_default();
+        let start_phase = plan.start.clone().unwrap_or_default();
+        let variables = plan.variables.clone().unwrap_or_default();
+        let static_assets = plan.static_assets.clone().unwrap_or_default();
+
+        // -- Variables
+        let args_string = if !variables.is_empty() {
+            format!(
+                "ARG {}\nENV {}",
+                // Pull the variables in from docker `--build-arg`
+                variables
+                    .iter()
+                    .map(|var| var.0.to_string())
+                    .collect::<Vec<_>>()
+                    .join(" "),
+                // Make the variables available at runtime
+                variables
+                    .iter()
+                    .map(|var| format!("{}=${}", var.0, var.0))
+                    .collect::<Vec<_>>()
+                    .join(" ")
+            )
+        } else {
+            "".to_string()
+        };
+
+        // -- Setup
+        let mut setup_files: Vec<String> = vec!["environment.nix".to_string()];
+        if let Some(mut setup_file_deps) = setup_phase.only_include_files {
+            setup_files.append(&mut setup_file_deps);
+        }
+        let setup_copy_cmd = format!("COPY {} {}", setup_files.join(" "), app_dir);
+
+        // -- Static Assets
+        let assets_copy_cmd = if !static_assets.is_empty() {
+            static_assets
+                .into_keys()
+                .map(|name| format!("COPY assets/{} {}{}", name, assets_dir, name))
+                .collect::<Vec<String>>()
+                .join("\n")
+        } else {
+            "".to_string()
+        };
+
+        // -- Install
+        let install_cmd = install_phase
+            .cmd
+            .clone()
+            .map(|cmd| format!("RUN {}", cmd))
+            .unwrap_or_else(|| "".to_string());
+
+        let path_env = if let Some(paths) = install_phase.paths {
+            format!("ENV PATH {}:$PATH", paths.join(":"))
+        } else {
+            "".to_string()
+        };
+
+        // Files to copy for install phase
+        // If none specified, copy over the entire app
+        let install_files = install_phase
+            .only_include_files
+            .clone()
+            .unwrap_or_else(|| vec![".".to_string()]);
+
+        // -- Build
+        let build_cmd = build_phase
+            .cmd
+            .clone()
+            .map(|cmd| format!("RUN {}", cmd))
+            .unwrap_or_else(|| "".to_string());
+
+        let build_files = build_phase.only_include_files.unwrap_or_else(|| {
+            // Only copy over the entire app if we haven't already in the install phase
+            if install_phase.only_include_files.is_none() {
+                Vec::new()
+            } else {
+                vec![".".to_string()]
+            }
+        });
+
+        // -- Start
+        let start_cmd = start_phase
+            .cmd
+            .map(|cmd| format!("CMD {}", cmd))
+            .unwrap_or_else(|| "".to_string());
+
+        // If we haven't yet copied over the entire app, do that before starting
+        let start_files = start_phase.only_include_files.clone();
+
+        let run_image_setup = match start_phase.run_image {
+            Some(run_image) => {
+                // RUN true to prevent a Docker bug https://github.com/moby/moby/issues/37965#issuecomment-426853382
+                format! {"
+                FROM {run_image}
+                WORKDIR {app_dir}
+                COPY --from=0 /etc/ssl/certs /etc/ssl/certs
+                RUN true
+                {copy_cmd}
+            ",
+                    run_image=run_image,
+                    app_dir=app_dir,
+                    copy_cmd=get_copy_from_command("0", &start_files.unwrap_or_default(), app_dir)
+                }
+            }
+            None => get_copy_command(
+                // If no files specified and no run image, copy everything in /app/ over
+                &start_files.unwrap_or_else(|| vec![".".to_string()]),
+                app_dir,
+            ),
+        };
+
+        let dockerfile = formatdoc! {"
+          FROM {base_image}
+
+          WORKDIR {app_dir}
+
+          # Setup
+          {setup_copy_cmd}
+          RUN nix-env -if environment.nix
+          {assets_copy_cmd}
+
+          # Load environment variables
+          {args_string}
+
+          # Install
+          {install_copy_cmd}
+          {install_cmd}
+          {path_env}
+
+          # Build
+          {build_copy_cmd}
+          {build_cmd}
+
+          # Start
+          {run_image_setup}
+          {start_cmd}
+        ",
+        base_image=setup_phase.base_image,
+        install_copy_cmd=get_copy_command(&install_files, app_dir),
+        build_copy_cmd=get_copy_command(&build_files, app_dir)};
+
+        dockerfile
+    }
+}
+
+fn get_copy_command(files: &[String], app_dir: &str) -> String {
+    if files.is_empty() {
+        "".to_owned()
+    } else {
+        format!("COPY {} {}", files.join(" "), app_dir)
+    }
+}
+
+fn get_copy_from_command(from: &str, files: &[String], app_dir: &str) -> String {
+    if files.is_empty() {
+        format!("COPY --from=0 {} {}", app_dir, app_dir)
+    } else {
+        format!(
+            "COPY --from={} {} {}",
+            from,
+            files
+                .iter()
+                .map(|f| f.replace("./", app_dir))
+                .collect::<Vec<_>>()
+                .join(" "),
+            app_dir
+        )
+    }
+}
diff --git a/src/nixpacks/builder/mod.rs b/src/nixpacks/builder/mod.rs
new file mode 100644
index 0000000..7df9ddc
--- /dev/null
+++ b/src/nixpacks/builder/mod.rs
@@ -0,0 +1,8 @@
+use super::plan::BuildPlan;
+use anyhow::Result;
+
+pub mod docker;
+
+pub trait Builder {
+    fn create_image(&self, app_source: &str, plan: &BuildPlan) -> Result<()>;
+}
diff --git a/src/nixpacks/environment.rs b/src/nixpacks/environment.rs
index 22fca83..7f22ed8 100644
--- a/src/nixpacks/environment.rs
+++ b/src/nixpacks/environment.rs
@@ -1,4 +1,5 @@
-use std::collections::HashMap;
+use anyhow::{bail, Result};
+use std::{collections::HashMap, env};
 
 pub type EnvironmentVariables = HashMap<String, String>;
 
@@ -12,6 +13,28 @@ impl Environment {
         Environment { variables }
     }
 
+    pub fn from_envs(envs: Vec<&str>) -> Result<Environment> {
+        let mut environment = Environment::default();
+        for env in envs {
+            let v: Vec<&str> = env.split('=').collect();
+            if v.len() == 1 {
+                // Pull the variable from the current environment
+                let name = v[0];
+                if let Ok(value) = env::var(name) {
+                    // Variable is set
+                    environment.set_variable(name.to_string(), value);
+                }
+            } else if v.len() > 2 {
+                bail!("Unable to parse variable string");
+            } else {
+                // Use provided name, value pair
+                environment.set_variable(v[0].to_string(), v[1].to_string());
+            }
+        }
+
+        Ok(environment)
+    }
+
     pub fn get_variable(&self, name: &str) -> Option<&String> {
         self.variables.get(name)
     }
@@ -55,4 +78,24 @@ mod tests {
             Some(&"world".to_string())
         );
     }
+
+    #[test]
+    fn test_environment_variable_parsing() {
+        let environment =
+            Environment::from_envs(vec!["HELLO=world", "CARGO_PKG_NAME", "NON_EXISTANT"]).unwrap();
+        assert_eq!(
+            environment.get_variable("HELLO"),
+            Some(&"world".to_string())
+        );
+        assert_eq!(
+            environment.get_variable("CARGO_PKG_NAME"),
+            Some(&"nixpacks".to_string())
+        );
+        assert!(environment.get_variable("NON_EXISTANT").is_none());
+    }
+
+    #[test]
+    fn test_create_invalid_environment() {
+        assert!(Environment::from_envs(vec!["INVALID=ENV=CONFIG"]).is_err());
+    }
 }
diff --git a/src/nixpacks/files.rs b/src/nixpacks/files.rs
new file mode 100644
index 0000000..4694b6d
--- /dev/null
+++ b/src/nixpacks/files.rs
@@ -0,0 +1,28 @@
+use anyhow::{Ok, Result};
+use std::{fs, io, path::Path};
+use walkdir::WalkDir;
+
+pub fn recursive_copy_dir<T: AsRef<Path>, Q: AsRef<Path>>(source: T, dest: Q) -> Result<()> {
+    let walker = WalkDir::new(&source).follow_links(true);
+    for entry in walker {
+        let entry = entry?;
+
+        let from = entry.path();
+        let to = dest.as_ref().join(from.strip_prefix(&source)?);
+
+        // create directories
+        if entry.file_type().is_dir() {
+            if let Err(e) = fs::create_dir(to) {
+                match e.kind() {
+                    io::ErrorKind::AlreadyExists => {}
+                    _ => return Err(e.into()),
+                }
+            }
+        }
+        // copy files
+        else if entry.file_type().is_file() {
+            fs::copy(from, to)?;
+        }
+    }
+    Ok(())
+}
diff --git a/src/nixpacks/mod.rs b/src/nixpacks/mod.rs
index 8a522ad..3e2cfa6 100644
--- a/src/nixpacks/mod.rs
+++ b/src/nixpacks/mod.rs
@@ -1,677 +1,11 @@
-use anyhow::{bail, Context, Ok, Result};
-use indoc::formatdoc;
-use std::{
-    fs::{self, File},
-    io::{self, Write},
-    path::{Path, PathBuf},
-    process::Command,
-};
-use tempdir::TempDir;
-use uuid::Uuid;
-use walkdir::WalkDir;
 pub mod app;
+pub mod builder;
 pub mod environment;
+mod files;
 pub mod images;
 pub mod logger;
 pub mod nix;
 pub mod phase;
 pub mod plan;
 
-use crate::providers::Provider;
-
-use self::{
-    app::{App, StaticAssets},
-    environment::{Environment, EnvironmentVariables},
-    logger::Logger,
-    nix::Pkg,
-    phase::{BuildPhase, InstallPhase, SetupPhase, StartPhase},
-    plan::BuildPlan,
-};
-
-const NIX_PACKS_VERSION: &str = env!("CARGO_PKG_VERSION");
-
-// https://status.nixos.org/
-static NIXPKGS_ARCHIVE: &str = "41cc1d5d9584103be4108c1815c350e07c807036";
-
-#[derive(Debug)]
-pub struct AppBuilderOptions {
-    pub custom_build_cmd: Option<String>,
-    pub custom_start_cmd: Option<String>,
-    pub custom_pkgs: Vec<Pkg>,
-    pub pin_pkgs: bool,
-    pub out_dir: Option<String>,
-    pub plan_path: Option<String>,
-    pub tags: Vec<String>,
-    pub labels: Vec<String>,
-    pub quiet: bool,
-}
-
-impl AppBuilderOptions {
-    pub fn empty() -> AppBuilderOptions {
-        AppBuilderOptions {
-            custom_build_cmd: None,
-            custom_start_cmd: None,
-            custom_pkgs: Vec::new(),
-            pin_pkgs: false,
-            out_dir: None,
-            plan_path: None,
-            tags: Vec::new(),
-            labels: Vec::new(),
-            quiet: false,
-        }
-    }
-}
-
-pub struct AppBuilder<'a> {
-    name: Option<String>,
-    app: &'a App,
-    environment: &'a Environment,
-    logger: &'a Logger,
-    options: &'a AppBuilderOptions,
-    provider: Option<&'a dyn Provider>,
-}
-
-impl<'a> AppBuilder<'a> {
-    pub fn new(
-        name: Option<String>,
-        app: &'a App,
-        environment: &'a Environment,
-        logger: &'a Logger,
-        options: &'a AppBuilderOptions,
-    ) -> Result<AppBuilder<'a>> {
-        Ok(AppBuilder {
-            name,
-            app,
-            environment,
-            logger,
-            options,
-            provider: None,
-        })
-    }
-
-    pub fn plan(&mut self, providers: Vec<&'a dyn Provider>) -> Result<BuildPlan> {
-        // Load options from the best matching provider
-        self.detect(providers).context("Detecting provider")?;
-
-        let setup_phase = self.get_setup_phase().context("Getting setup phase")?;
-        let install_phase = self
-            .get_install_phase()
-            .context("Generating install phase")?;
-        let build_phase = self.get_build_phase().context("Generating build phase")?;
-        let start_phase = self.get_start_phase().context("Generating start phase")?;
-        let variables = self.get_variables().context("Getting plan variables")?;
-        let static_assets = self
-            .get_static_assets()
-            .context("Getting provider assets")?;
-
-        let plan = BuildPlan {
-            version: Some(NIX_PACKS_VERSION.to_string()),
-            setup: Some(setup_phase),
-            install: Some(install_phase),
-            build: Some(build_phase),
-            start: Some(start_phase),
-            variables: Some(variables),
-            static_assets: Some(static_assets),
-        };
-
-        Ok(plan)
-    }
-
-    pub fn build(&mut self, providers: Vec<&'a dyn Provider>) -> Result<()> {
-        self.logger
-            .log_section(format!("Building (nixpacks v{})", NIX_PACKS_VERSION).as_str());
-
-        let plan = match &self.options.plan_path {
-            Some(plan_path) => {
-                self.logger.log_step("Building from existing plan");
-                let plan_json = fs::read_to_string(plan_path).context("Reading build plan")?;
-                let plan: BuildPlan =
-                    serde_json::from_str(&plan_json).context("Deserializing build plan")?;
-                plan
-            }
-            None => {
-                self.logger.log_step("Generated new build plan");
-                self.plan(providers).context("Creating build plan")?
-            }
-        };
-
-        println!("{}", plan.get_build_string());
-
-        self.do_build(&plan)
-    }
-
-    pub fn do_build(&mut self, plan: &BuildPlan) -> Result<()> {
-        let id = Uuid::new_v4();
-
-        let dir = match &self.options.out_dir {
-            Some(dir) => dir.into(),
-            None => {
-                let tmp = TempDir::new("nixpacks").context("Creating a temp directory")?;
-                tmp.into_path()
-            }
-        };
-        let dir_path_str = dir.to_str().context("Invalid temp directory path")?;
-
-        // Copy files into temp directory
-        Self::recursive_copy_dir(&self.app.source, &dir)?;
-
-        AppBuilder::write_build_plan(plan, dir_path_str).context("Writing build plan")?;
-        self.logger.log_step("Building image with Docker");
-
-        let name = self.name.clone().unwrap_or_else(|| id.to_string());
-
-        if self.options.out_dir.is_none() {
-            let mut docker_build_cmd = Command::new("docker");
-            docker_build_cmd
-                .arg("build")
-                .arg(dir)
-                .arg("-t")
-                .arg(name.clone());
-
-            if self.options.quiet {
-                docker_build_cmd.arg("--quiet");
-            }
-
-            // Add build environment variables
-            for (name, value) in plan.variables.clone().unwrap_or_default().iter() {
-                docker_build_cmd
-                    .arg("--build-arg")
-                    .arg(format!("{}={}", name, value));
-            }
-
-            // Add user defined tags and labels to the image
-            for t in self.options.tags.clone() {
-                docker_build_cmd.arg("-t").arg(t);
-            }
-            for l in self.options.labels.clone() {
-                docker_build_cmd.arg("--label").arg(l);
-            }
-
-            let build_result = docker_build_cmd.spawn()?.wait().context("Building image")?;
-
-            if !build_result.success() {
-                bail!("Docker build failed")
-            }
-
-            self.logger.log_section("Successfully Built!");
-
-            println!("\nRun:");
-            println!("  docker run -it {}", name);
-        } else {
-            println!("\nSaved output to:");
-            println!("  {}", dir_path_str);
-        };
-
-        Ok(())
-    }
-
-    fn recursive_copy_dir<T: AsRef<Path>, Q: AsRef<Path>>(source: T, dest: Q) -> Result<()> {
-        let walker = WalkDir::new(&source).follow_links(true);
-        for entry in walker {
-            let entry = entry?;
-
-            let from = entry.path();
-            let to = dest.as_ref().join(from.strip_prefix(&source)?);
-
-            // create directories
-            if entry.file_type().is_dir() {
-                if let Err(e) = fs::create_dir(to) {
-                    match e.kind() {
-                        io::ErrorKind::AlreadyExists => {}
-                        _ => return Err(e.into()),
-                    }
-                }
-            }
-            // copy files
-            else if entry.file_type().is_file() {
-                fs::copy(from, to)?;
-            }
-        }
-        Ok(())
-    }
-
-    fn get_setup_phase(&self) -> Result<SetupPhase> {
-        let mut setup_phase: SetupPhase = match self.provider {
-            Some(provider) => provider
-                .setup(self.app, self.environment)?
-                .unwrap_or_default(),
-            None => SetupPhase::default(),
-        };
-
-        let env_var_pkgs = self
-            .environment
-            .get_config_variable("PKGS")
-            .map(|pkg_string| pkg_string.split(' ').map(Pkg::new).collect::<Vec<_>>())
-            .unwrap_or_default();
-
-        // Add custom user packages
-        let mut pkgs = [self.options.custom_pkgs.clone(), env_var_pkgs].concat();
-        setup_phase.add_pkgs(&mut pkgs);
-
-        if self.options.pin_pkgs {
-            setup_phase.set_archive(NIXPKGS_ARCHIVE.to_string())
-        }
-
-        Ok(setup_phase)
-    }
-
-    fn get_install_phase(&self) -> Result<InstallPhase> {
-        let install_phase = match self.provider {
-            Some(provider) => provider
-                .install(self.app, self.environment)?
-                .unwrap_or_default(),
-            None => InstallPhase::default(),
-        };
-
-        Ok(install_phase)
-    }
-
-    fn get_build_phase(&self) -> Result<BuildPhase> {
-        let mut build_phase = match self.provider {
-            Some(provider) => provider
-                .build(self.app, self.environment)?
-                .unwrap_or_default(),
-            None => BuildPhase::default(),
-        };
-
-        let env_build_cmd = self.environment.get_config_variable("BUILD_CMD").cloned();
-
-        // Build command priority
-        // - custom build command
-        // - environment variable
-        // - provider
-        build_phase.cmd = self
-            .options
-            .custom_build_cmd
-            .clone()
-            .or(env_build_cmd)
-            .or(build_phase.cmd);
-
-        Ok(build_phase)
-    }
-
-    fn get_start_phase(&self) -> Result<StartPhase> {
-        let procfile_cmd = self.parse_procfile()?;
-
-        let mut start_phase = match self.provider {
-            Some(provider) => provider
-                .start(self.app, self.environment)?
-                .unwrap_or_default(),
-            None => StartPhase::default(),
-        };
-
-        let env_start_cmd = self.environment.get_config_variable("START_CMD").cloned();
-
-        // Start command priority
-        // - custom start command
-        // - environment variable
-        // - procfile
-        // - provider
-        start_phase.cmd = self
-            .options
-            .custom_start_cmd
-            .clone()
-            .or_else(|| env_start_cmd.or_else(|| procfile_cmd.or(start_phase.cmd)));
-
-        // Allow the user to override the run image with an environment variable
-        if let Some(env_run_image) = self.environment.get_config_variable("RUN_IMAGE") {
-            // If the env var is "falsy", then unset the run image on the start phase
-            start_phase.run_image = match env_run_image.as_str() {
-                "0" | "false" => None,
-                img if img.is_empty() => None,
-                img => Some(img.to_owned()),
-            };
-        }
-
-        Ok(start_phase)
-    }
-
-    fn get_variables(&self) -> Result<EnvironmentVariables> {
-        // Get a copy of the variables in the environment
-        let variables = Environment::clone_variables(self.environment);
-
-        let new_variables = match self.provider {
-            Some(provider) => {
-                // Merge provider variables
-                let provider_variables = provider
-                    .environment_variables(self.app, self.environment)?
-                    .unwrap_or_default();
-                provider_variables.into_iter().chain(variables).collect()
-            }
-            None => variables,
-        };
-
-        Ok(new_variables)
-    }
-
-    fn get_static_assets(&self) -> Result<StaticAssets> {
-        let static_assets = match self.provider {
-            Some(provider) => provider
-                .static_assets(self.app, self.environment)?
-                .unwrap_or_default(),
-            None => StaticAssets::new(),
-        };
-
-        Ok(static_assets)
-    }
-
-    fn detect(&mut self, providers: Vec<&'a dyn Provider>) -> Result<()> {
-        for provider in providers {
-            let matches = provider.detect(self.app, self.environment)?;
-            if matches {
-                self.provider = Some(provider);
-                break;
-            }
-        }
-
-        Ok(())
-    }
-
-    fn parse_procfile(&self) -> Result<Option<String>> {
-        if self.app.includes_file("Procfile") {
-            let contents = self.app.read_file("Procfile")?;
-
-            // Better error handling
-            if contents.starts_with("web: ") {
-                return Ok(Some(contents.replace("web: ", "").trim().to_string()));
-            }
-
-            Ok(None)
-        } else {
-            Ok(None)
-        }
-    }
-
-    pub fn write_build_plan(plan: &BuildPlan, dest: &str) -> Result<()> {
-        let nix_expression = AppBuilder::gen_nix(plan).context("Generating Nix expression")?;
-        let dockerfile = AppBuilder::gen_dockerfile(plan).context("Generating Dockerfile")?;
-
-        let nix_path = PathBuf::from(dest).join(PathBuf::from("environment.nix"));
-        let mut nix_file = File::create(nix_path).context("Creating Nix environment file")?;
-        nix_file
-            .write_all(nix_expression.as_bytes())
-            .context("Unable to write Nix expression")?;
-
-        let dockerfile_path = PathBuf::from(dest).join(PathBuf::from("Dockerfile"));
-        File::create(dockerfile_path.clone()).context("Creating Dockerfile file")?;
-        fs::write(dockerfile_path, dockerfile).context("Writing Dockerfile")?;
-
-        if let Some(assets) = &plan.static_assets {
-            if !assets.is_empty() {
-                let static_assets_path = PathBuf::from(dest).join(PathBuf::from("assets"));
-                fs::create_dir_all(&static_assets_path).context("Creating static assets folder")?;
-
-                for (name, content) in assets {
-                    let path = Path::new(&static_assets_path).join(name);
-                    let parent = path.parent().unwrap();
-                    fs::create_dir_all(parent)
-                        .context(format!("Creating parent directory for {}", name))?;
-                    let mut file =
-                        File::create(path).context(format!("Creating asset file for {name}"))?;
-                    file.write_all(content.as_bytes())
-                        .context(format!("Writing asset {name}"))?;
-                }
-            }
-        }
-
-        Ok(())
-    }
-
-    pub fn gen_nix(plan: &BuildPlan) -> Result<String> {
-        let setup_phase = plan.setup.clone().unwrap_or_default();
-
-        let nixpkgs = setup_phase
-            .pkgs
-            .iter()
-            .map(|p| p.to_nix_string())
-            .collect::<Vec<String>>()
-            .join(" ");
-
-        let nix_archive = setup_phase.archive.clone();
-        let pkg_import = match nix_archive {
-            Some(archive) => format!(
-                "import (fetchTarball \"https://github.com/NixOS/nixpkgs/archive/{}.tar.gz\")",
-                archive
-            ),
-            None => "import <nixpkgs>".to_string(),
-        };
-
-        let mut overlays: Vec<String> = Vec::new();
-        for pkg in &setup_phase.pkgs {
-            if let Some(overlay) = &pkg.overlay {
-                overlays.push(overlay.to_string());
-            }
-        }
-        let overlays_string = overlays
-            .iter()
-            .map(|url| format!("(import (builtins.fetchTarball \"{}\"))", url))
-            .collect::<Vec<String>>()
-            .join("\n");
-
-        let nix_expression = formatdoc! {"
-            {{ }}:
-
-            let
-              pkgs = {pkg_import} {{ 
-                overlays = [
-                  {overlays}
-                ];
-              }};
-            in with pkgs;
-            buildEnv {{
-              name = \"env\";
-              paths = [
-                {pkgs}
-              ];
-            }}
-        ",
-        pkg_import=pkg_import,
-        pkgs=nixpkgs,
-        overlays=overlays_string};
-
-        Ok(nix_expression)
-    }
-
-    pub fn gen_dockerfile(plan: &BuildPlan) -> Result<String> {
-        let app_dir = "/app/";
-        let assets_dir = app::ASSETS_DIR;
-
-        let setup_phase = plan.setup.clone().unwrap_or_default();
-        let install_phase = plan.install.clone().unwrap_or_default();
-        let build_phase = plan.build.clone().unwrap_or_default();
-        let start_phase = plan.start.clone().unwrap_or_default();
-        let variables = plan.variables.clone().unwrap_or_default();
-        let static_assets = plan.static_assets.clone().unwrap_or_default();
-
-        // -- Variables
-        let args_string = if !variables.is_empty() {
-            format!(
-                "ARG {}\nENV {}",
-                // Pull the variables in from docker `--build-arg`
-                variables
-                    .iter()
-                    .map(|var| var.0.to_string())
-                    .collect::<Vec<_>>()
-                    .join(" "),
-                // Make the variables available at runtime
-                variables
-                    .iter()
-                    .map(|var| format!("{}=${}", var.0, var.0))
-                    .collect::<Vec<_>>()
-                    .join(" ")
-            )
-        } else {
-            "".to_string()
-        };
-
-        // -- Setup
-        let mut setup_files: Vec<String> = vec!["environment.nix".to_string()];
-        if let Some(mut setup_file_deps) = setup_phase.only_include_files {
-            setup_files.append(&mut setup_file_deps);
-        }
-        let setup_copy_cmd = format!("COPY {} {}", setup_files.join(" "), app_dir);
-
-        // -- Static Assets
-        let assets_copy_cmd = if !static_assets.is_empty() {
-            static_assets
-                .clone()
-                .into_keys()
-                .map(|name| format!("COPY assets/{} {}{}", name, assets_dir, name))
-                .collect::<Vec<String>>()
-                .join("\n")
-        } else {
-            "".to_string()
-        };
-
-        // -- Install
-        let install_cmd = install_phase
-            .cmd
-            .clone()
-            .map(|cmd| format!("RUN {}", cmd))
-            .unwrap_or_else(|| "".to_string());
-
-        let path_env = if let Some(paths) = install_phase.paths {
-            format!("ENV PATH {}:$PATH", paths.join(":"))
-        } else {
-            "".to_string()
-        };
-
-        // Files to copy for install phase
-        // If none specified, copy over the entire app
-        let install_files = install_phase
-            .only_include_files
-            .clone()
-            .unwrap_or_else(|| vec![".".to_string()]);
-
-        // -- Build
-        let build_cmd = build_phase
-            .cmd
-            .clone()
-            .map(|cmd| format!("RUN {}", cmd))
-            .unwrap_or_else(|| "".to_string());
-
-        let build_files = build_phase.only_include_files.unwrap_or_else(|| {
-            // Only copy over the entire app if we haven't already in the install phase
-            if install_phase.only_include_files.is_none() {
-                Vec::new()
-            } else {
-                vec![".".to_string()]
-            }
-        });
-
-        // -- Start
-        let start_cmd = start_phase
-            .cmd
-            .map(|cmd| format!("CMD {}", cmd))
-            .unwrap_or_else(|| "".to_string());
-
-        // If we haven't yet copied over the entire app, do that before starting
-        let start_files = start_phase.only_include_files.clone();
-
-        let run_image_setup = match start_phase.run_image {
-            Some(run_image) => {
-                // RUN true to prevent a Docker bug https://github.com/moby/moby/issues/37965#issuecomment-426853382
-                formatdoc! {"
-                FROM {run_image}
-                WORKDIR {app_dir}
-                COPY --from=0 /etc/ssl/certs /etc/ssl/certs
-                RUN true
-                {copy_cmd}
-            ",
-                    run_image=run_image,
-                    app_dir=app_dir,
-                    copy_cmd=get_copy_from_command("0", &start_files.unwrap_or_default(), app_dir, !static_assets.is_empty())
-                }
-            }
-            None => get_copy_command(
-                // If no files specified and no run image, copy everything in /app/ over
-                &start_files.unwrap_or_else(|| vec![".".to_string()]),
-                app_dir,
-            ),
-        };
-
-        let dockerfile = formatdoc! {"
-          FROM {base_image}
-
-          WORKDIR {app_dir}
-
-          # Setup
-          {setup_copy_cmd}
-          RUN nix-env -if environment.nix
-          {assets_copy_cmd}
-
-          # Load environment variables
-          {args_string}
-
-          # Install
-          {install_copy_cmd}
-          {install_cmd}
-          {path_env}
-
-          # Build
-          {build_copy_cmd}
-          {build_cmd}
-
-          # Start
-          {run_image_setup}
-          {start_cmd}
-        ",
-        base_image=setup_phase.base_image,
-        setup_copy_cmd=setup_copy_cmd,
-        args_string=args_string,
-        install_copy_cmd=get_copy_command(&install_files, app_dir),
-        install_cmd=install_cmd,
-        path_env=path_env,
-        build_copy_cmd=get_copy_command(&build_files, app_dir),
-        build_cmd=build_cmd,
-        run_image_setup=run_image_setup,
-        start_cmd=start_cmd};
-
-        Ok(dockerfile)
-    }
-}
-
-fn get_copy_command(files: &[String], app_dir: &str) -> String {
-    if files.is_empty() {
-        "".to_owned()
-    } else {
-        format!("COPY {} {}", files.join(" "), app_dir)
-    }
-}
-
-fn get_copy_from_command(from: &str, files: &[String], app_dir: &str, has_assets: bool) -> String {
-    if files.is_empty() {
-        format!(
-            "
-COPY --from=0 {} {}
-RUN true
-{}
-",
-            app_dir,
-            app_dir,
-            if has_assets {
-                "COPY --from=0 /assets /assets"
-            } else {
-                ""
-            }
-        )
-    } else {
-        format!(
-            "COPY --from={} {} {}
-            RUN true
-            {}",
-            from,
-            files
-                .iter()
-                .map(|f| f.replace("./", app_dir))
-                .collect::<Vec<_>>()
-                .join(" "),
-            app_dir,
-            if has_assets {
-                format!("COPY --from={} /assets /assets", from)
-            } else {
-                "".to_string()
-            }
-        )
-    }
-}
+pub const NIX_PACKS_VERSION: &str = env!("CARGO_PKG_VERSION");
diff --git a/src/nixpacks/nix/mod.rs b/src/nixpacks/nix/mod.rs
new file mode 100644
index 0000000..af1cae7
--- /dev/null
+++ b/src/nixpacks/nix/mod.rs
@@ -0,0 +1,59 @@
+use super::plan::BuildPlan;
+use indoc::formatdoc;
+
+pub mod pkg;
+
+pub fn create_nix_expression(plan: &BuildPlan) -> String {
+    let setup_phase = plan.setup.clone().unwrap_or_default();
+
+    let nixpkgs = setup_phase
+        .pkgs
+        .iter()
+        .map(|p| p.to_nix_string())
+        .collect::<Vec<String>>()
+        .join(" ");
+
+    let nix_archive = setup_phase.archive.clone();
+    let pkg_import = match nix_archive {
+        Some(archive) => format!(
+            "import (fetchTarball \"https://github.com/NixOS/nixpkgs/archive/{}.tar.gz\")",
+            archive
+        ),
+        None => "import <nixpkgs>".to_string(),
+    };
+
+    let mut overlays: Vec<String> = Vec::new();
+    for pkg in &setup_phase.pkgs {
+        if let Some(overlay) = &pkg.overlay {
+            overlays.push(overlay.to_string());
+        }
+    }
+    let overlays_string = overlays
+        .iter()
+        .map(|url| format!("(import (builtins.fetchTarball \"{}\"))", url))
+        .collect::<Vec<String>>()
+        .join("\n");
+
+    let nix_expression = formatdoc! {"
+            {{ }}:
+
+            let
+              pkgs = {pkg_import} {{ 
+                overlays = [
+                  {overlays}
+                ];
+              }};
+            in with pkgs;
+            buildEnv {{
+              name = \"env\";
+              paths = [
+                {pkgs}
+              ];
+            }}
+        ",
+    pkg_import=pkg_import,
+    pkgs=nixpkgs,
+    overlays=overlays_string};
+
+    nix_expression
+}
diff --git a/src/nixpacks/nix.rs b/src/nixpacks/nix/pkg.rs
similarity index 92%
rename from src/nixpacks/nix.rs
rename to src/nixpacks/nix/pkg.rs
index 7f09c5b..abab40f 100644
--- a/src/nixpacks/nix.rs
+++ b/src/nixpacks/nix/pkg.rs
@@ -1,6 +1,5 @@
-use std::collections::HashMap;
-
 use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
 
 #[serde_with::skip_serializing_none]
 #[derive(Serialize, Deserialize, PartialEq, Clone, Debug)]
@@ -10,13 +9,6 @@ pub struct Pkg {
     pub overrides: Option<HashMap<String, String>>,
 }
 
-#[serde_with::skip_serializing_none]
-#[derive(Serialize, Deserialize, PartialEq, Clone, Default, Debug)]
-pub struct NixConfig {
-    pub pkgs: Vec<Pkg>,
-    pub archive: Option<String>,
-}
-
 impl Pkg {
     pub fn new(name: &str) -> Pkg {
         Pkg {
diff --git a/src/nixpacks/phase.rs b/src/nixpacks/phase.rs
index 478de9d..6550358 100644
--- a/src/nixpacks/phase.rs
+++ b/src/nixpacks/phase.rs
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
 
 use super::{
     images::{DEBIAN_SLIM_IMAGE, DEFAULT_BASE_IMAGE},
-    nix::Pkg,
+    nix::pkg::Pkg,
 };
 
 #[serde_with::skip_serializing_none]
diff --git a/src/nixpacks/plan/generator.rs b/src/nixpacks/plan/generator.rs
new file mode 100644
index 0000000..d1afea0
--- /dev/null
+++ b/src/nixpacks/plan/generator.rs
@@ -0,0 +1,233 @@
+use std::fs;
+
+use super::{BuildPlan, PlanGenerator};
+use crate::{
+    nixpacks::{
+        app::{App, StaticAssets},
+        environment::{Environment, EnvironmentVariables},
+        nix::pkg::Pkg,
+        phase::{BuildPhase, InstallPhase, SetupPhase, StartPhase},
+        NIX_PACKS_VERSION,
+    },
+    providers::Provider,
+};
+use anyhow::{Context, Ok, Result};
+
+// https://status.nixos.org/
+static NIXPKGS_ARCHIVE: &str = "41cc1d5d9584103be4108c1815c350e07c807036";
+
+#[derive(Clone, Default, Debug)]
+pub struct GeneratePlanOptions {
+    pub custom_build_cmd: Option<String>,
+    pub custom_start_cmd: Option<String>,
+    pub custom_pkgs: Vec<Pkg>,
+    pub pin_pkgs: bool,
+    pub plan_path: Option<String>,
+}
+
+pub struct NixpacksBuildPlanGenerator<'a> {
+    providers: Vec<&'a dyn Provider>,
+    matched_provider: Option<&'a dyn Provider>,
+    options: GeneratePlanOptions,
+}
+
+impl<'a> PlanGenerator for NixpacksBuildPlanGenerator<'a> {
+    fn generate_plan(&mut self, app: &App, environment: &Environment) -> Result<BuildPlan> {
+        // If options.plan_path is specified, use that build plan
+        if let Some(plan_path) = self.options.clone().plan_path {
+            let plan_json = fs::read_to_string(plan_path).context("Reading build plan")?;
+            let plan: BuildPlan =
+                serde_json::from_str(&plan_json).context("Deserializing build plan")?;
+            return Ok(plan);
+        }
+
+        self.detect(app, environment)?;
+
+        let setup_phase = self
+            .get_setup_phase(app, environment)
+            .context("Getting setup phase")?;
+        let install_phase = self
+            .get_install_phase(app, environment)
+            .context("Generating install phase")?;
+        let build_phase = self
+            .get_build_phase(app, environment)
+            .context("Generating build phase")?;
+        let start_phase = self
+            .get_start_phase(app, environment)
+            .context("Generating start phase")?;
+        let variables = self
+            .get_variables(app, environment)
+            .context("Getting plan variables")?;
+        let static_assets = self
+            .get_static_assets(app, environment)
+            .context("Getting provider assets")?;
+
+        let plan = BuildPlan {
+            version: Some(NIX_PACKS_VERSION.to_string()),
+            setup: Some(setup_phase),
+            install: Some(install_phase),
+            build: Some(build_phase),
+            start: Some(start_phase),
+            variables: Some(variables),
+            static_assets: Some(static_assets),
+        };
+
+        Ok(plan)
+    }
+}
+
+impl<'a> NixpacksBuildPlanGenerator<'a> {
+    pub fn new(
+        providers: Vec<&'a dyn Provider>,
+        options: GeneratePlanOptions,
+    ) -> NixpacksBuildPlanGenerator {
+        NixpacksBuildPlanGenerator {
+            providers,
+            matched_provider: None,
+            options,
+        }
+    }
+
+    fn detect(&mut self, app: &App, environment: &Environment) -> Result<()> {
+        for provider in self.providers.clone() {
+            let matches = provider.detect(app, environment)?;
+            if matches {
+                self.matched_provider = Some(provider);
+                break;
+            }
+        }
+
+        Ok(())
+    }
+
+    fn get_setup_phase(&self, app: &App, environment: &Environment) -> Result<SetupPhase> {
+        let mut setup_phase: SetupPhase = match self.matched_provider {
+            Some(provider) => provider.setup(app, environment)?.unwrap_or_default(),
+            None => SetupPhase::default(),
+        };
+
+        let env_var_pkgs = environment
+            .get_config_variable("PKGS")
+            .map(|pkg_string| pkg_string.split(' ').map(Pkg::new).collect::<Vec<_>>())
+            .unwrap_or_default();
+
+        // Add custom user packages
+        let mut pkgs = [self.options.custom_pkgs.clone(), env_var_pkgs].concat();
+        setup_phase.add_pkgs(&mut pkgs);
+
+        if self.options.pin_pkgs {
+            setup_phase.set_archive(NIXPKGS_ARCHIVE.to_string())
+        }
+
+        Ok(setup_phase)
+    }
+
+    fn get_install_phase(&self, app: &App, environment: &Environment) -> Result<InstallPhase> {
+        let install_phase = match self.matched_provider {
+            Some(provider) => provider.install(app, environment)?.unwrap_or_default(),
+            None => InstallPhase::default(),
+        };
+
+        Ok(install_phase)
+    }
+
+    fn get_build_phase(&self, app: &App, environment: &Environment) -> Result<BuildPhase> {
+        let mut build_phase = match self.matched_provider {
+            Some(provider) => provider.build(app, environment)?.unwrap_or_default(),
+            None => BuildPhase::default(),
+        };
+
+        let env_build_cmd = environment.get_config_variable("BUILD_CMD").cloned();
+
+        // Build command priority
+        // - custom build command
+        // - environment variable
+        // - provider
+        build_phase.cmd = self
+            .options
+            .custom_build_cmd
+            .clone()
+            .or(env_build_cmd)
+            .or(build_phase.cmd);
+
+        Ok(build_phase)
+    }
+
+    fn get_start_phase(&self, app: &App, environment: &Environment) -> Result<StartPhase> {
+        let procfile_cmd = self.parse_procfile(app)?;
+
+        let mut start_phase = match self.matched_provider {
+            Some(provider) => provider.start(app, environment)?.unwrap_or_default(),
+            None => StartPhase::default(),
+        };
+
+        let env_start_cmd = environment.get_config_variable("START_CMD").cloned();
+
+        // Start command priority
+        // - custom start command
+        // - environment variable
+        // - procfile
+        // - provider
+        start_phase.cmd = self
+            .options
+            .custom_start_cmd
+            .clone()
+            .or_else(|| env_start_cmd.or_else(|| procfile_cmd.or(start_phase.cmd)));
+
+        // Allow the user to override the run image with an environment variable
+        if let Some(env_run_image) = environment.get_config_variable("RUN_IMAGE") {
+            // If the env var is "falsy", then unset the run image on the start phase
+            start_phase.run_image = match env_run_image.as_str() {
+                "0" | "false" => None,
+                img if img.is_empty() => None,
+                img => Some(img.to_owned()),
+            };
+        }
+
+        Ok(start_phase)
+    }
+
+    fn get_variables(&self, app: &App, environment: &Environment) -> Result<EnvironmentVariables> {
+        // Get a copy of the variables in the environment
+        let variables = Environment::clone_variables(environment);
+
+        let new_variables = match self.matched_provider {
+            Some(provider) => {
+                // Merge provider variables
+                let provider_variables = provider
+                    .environment_variables(app, environment)?
+                    .unwrap_or_default();
+                provider_variables.into_iter().chain(variables).collect()
+            }
+            None => variables,
+        };
+
+        Ok(new_variables)
+    }
+
+    fn get_static_assets(&self, app: &App, environment: &Environment) -> Result<StaticAssets> {
+        let static_assets = match self.matched_provider {
+            Some(provider) => provider
+                .static_assets(app, environment)?
+                .unwrap_or_default(),
+            None => StaticAssets::new(),
+        };
+
+        Ok(static_assets)
+    }
+
+    fn parse_procfile(&self, app: &App) -> Result<Option<String>> {
+        if app.includes_file("Procfile") {
+            let contents = app.read_file("Procfile")?;
+
+            // Better error handling
+            if contents.starts_with("web: ") {
+                return Ok(Some(contents.replace("web: ", "").trim().to_string()));
+            }
+
+            Ok(None)
+        } else {
+            Ok(None)
+        }
+    }
+}
diff --git a/src/nixpacks/plan.rs b/src/nixpacks/plan/mod.rs
similarity index 88%
rename from src/nixpacks/plan.rs
rename to src/nixpacks/plan/mod.rs
index c7d857e..737cda4 100644
--- a/src/nixpacks/plan.rs
+++ b/src/nixpacks/plan/mod.rs
@@ -1,11 +1,13 @@
+use crate::nixpacks::{
+    app::{App, StaticAssets},
+    environment::{Environment, EnvironmentVariables},
+    phase::{BuildPhase, InstallPhase, SetupPhase, StartPhase},
+};
+use anyhow::Result;
 use indoc::formatdoc;
 use serde::{Deserialize, Serialize};
 
-use super::{
-    environment::EnvironmentVariables,
-    phase::{BuildPhase, InstallPhase, SetupPhase, StartPhase},
-    StaticAssets,
-};
+pub mod generator;
 
 #[serde_with::skip_serializing_none]
 #[derive(Debug, Serialize, Deserialize)]
@@ -19,6 +21,10 @@ pub struct BuildPlan {
     pub static_assets: Option<StaticAssets>,
 }
 
+pub trait PlanGenerator {
+    fn generate_plan(&mut self, app: &App, environment: &Environment) -> Result<BuildPlan>;
+}
+
 impl BuildPlan {
     pub fn get_build_string(&self) -> String {
         let setup_phase = self.setup.clone();
diff --git a/src/providers/crystal.rs b/src/providers/crystal.rs
index 3e7f56c..8548317 100644
--- a/src/providers/crystal.rs
+++ b/src/providers/crystal.rs
@@ -4,7 +4,7 @@ use super::Provider;
 use crate::nixpacks::{
     app::App,
     environment::Environment,
-    nix::Pkg,
+    nix::pkg::Pkg,
     phase::{BuildPhase, InstallPhase, SetupPhase, StartPhase},
 };
 use anyhow::{Context, Result};
diff --git a/src/providers/deno.rs b/src/providers/deno.rs
index 6193297..bf26823 100644
--- a/src/providers/deno.rs
+++ b/src/providers/deno.rs
@@ -4,7 +4,7 @@ use super::Provider;
 use crate::nixpacks::{
     app::App,
     environment::Environment,
-    nix::Pkg,
+    nix::pkg::Pkg,
     phase::{BuildPhase, SetupPhase, StartPhase},
 };
 use anyhow::{Context, Result};
diff --git a/src/providers/go.rs b/src/providers/go.rs
index 281bfef..3f038b2 100644
--- a/src/providers/go.rs
+++ b/src/providers/go.rs
@@ -2,7 +2,7 @@ use super::Provider;
 use crate::nixpacks::{
     app::App,
     environment::{Environment, EnvironmentVariables},
-    nix::Pkg,
+    nix::pkg::Pkg,
     phase::{BuildPhase, InstallPhase, SetupPhase, StartPhase},
 };
 use anyhow::Result;
diff --git a/src/providers/haskell.rs b/src/providers/haskell.rs
index afad653..7fa964f 100644
--- a/src/providers/haskell.rs
+++ b/src/providers/haskell.rs
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
 use serde::Deserialize;
 
 use crate::nixpacks::{
-    nix::Pkg,
+    nix::pkg::Pkg,
     phase::{BuildPhase, InstallPhase, SetupPhase, StartPhase},
 };
 
diff --git a/src/providers/node.rs b/src/providers/node.rs
index d900aa7..57c110d 100644
--- a/src/providers/node.rs
+++ b/src/providers/node.rs
@@ -4,7 +4,7 @@ use super::Provider;
 use crate::nixpacks::{
     app::App,
     environment::{Environment, EnvironmentVariables},
-    nix::Pkg,
+    nix::pkg::Pkg,
     phase::{BuildPhase, InstallPhase, SetupPhase, StartPhase},
 };
 use anyhow::{bail, Result};
diff --git a/src/providers/python.rs b/src/providers/python.rs
index 93b11b7..a6aaca4 100644
--- a/src/providers/python.rs
+++ b/src/providers/python.rs
@@ -210,7 +210,7 @@ impl PythonProvider {
 #[cfg(test)]
 mod test {
     use super::*;
-    use crate::nixpacks::{app::App, environment::Environment, nix::Pkg};
+    use crate::nixpacks::{app::App, environment::Environment, nix::pkg::Pkg};
     use std::collections::HashMap;
 
     #[test]
diff --git a/src/providers/rust.rs b/src/providers/rust.rs
index 15001be..86edd4f 100644
--- a/src/providers/rust.rs
+++ b/src/providers/rust.rs
@@ -4,7 +4,7 @@ use super::Provider;
 use crate::nixpacks::{
     app::App,
     environment::{Environment, EnvironmentVariables},
-    nix::Pkg,
+    nix::pkg::Pkg,
     phase::{BuildPhase, SetupPhase, StartPhase},
 };
 use anyhow::{Context, Result};
@@ -166,7 +166,7 @@ impl RustProvider {
 mod test {
     use std::collections::HashMap;
 
-    use crate::nixpacks::{app::App, environment::Environment, nix::Pkg};
+    use crate::nixpacks::{app::App, environment::Environment, nix::pkg::Pkg};
 
     use super::*;
 


Test patch:
diff --git a/tests/docker_run_tests.rs b/tests/docker_run_tests.rs
index 095b453..b275f4c 100644
--- a/tests/docker_run_tests.rs
+++ b/tests/docker_run_tests.rs
@@ -1,4 +1,9 @@
-use nixpacks::build;
+use nixpacks::{
+    create_docker_image,
+    nixpacks::{
+        builder::docker::DockerBuilderOptions, nix::pkg::Pkg, plan::generator::GeneratePlanOptions,
+    },
+};
 use std::io::{BufRead, BufReader};
 use std::{
     process::{Command, Stdio},
@@ -89,19 +94,18 @@ fn run_image(name: String) -> String {
 /// Returns the randomly generated image name
 fn simple_build(path: &str) -> String {
     let name = Uuid::new_v4().to_string();
-    build(
+    create_docker_image(
         path,
-        Some(name.clone()),
-        Vec::new(),
-        None,
-        None,
-        true,
-        Vec::new(),
-        None,
-        None,
         Vec::new(),
-        Vec::new(),
-        true,
+        &GeneratePlanOptions {
+            pin_pkgs: true,
+            ..Default::default()
+        },
+        &DockerBuilderOptions {
+            name: Some(name.clone()),
+            quiet: true,
+            ..Default::default()
+        },
     )
     .unwrap();
 
@@ -187,19 +191,18 @@ fn test_python_poetry() {
 #[test]
 fn test_rust_custom_version() {
     let name = Uuid::new_v4().to_string();
-    build(
+    create_docker_image(
         "./examples/rust-custom-version",
-        Some(name.clone()),
-        Vec::new(),
-        None,
-        None,
-        true,
         vec!["NIXPACKS_NO_MUSL=1"],
-        None,
-        None,
-        Vec::new(),
-        Vec::new(),
-        true,
+        &GeneratePlanOptions {
+            pin_pkgs: true,
+            ..Default::default()
+        },
+        &DockerBuilderOptions {
+            name: Some(name.clone()),
+            quiet: true,
+            ..Default::default()
+        },
     )
     .unwrap();
 
@@ -238,21 +241,23 @@ fn test_crystal() {
 #[test]
 fn test_cowsay() {
     let name = Uuid::new_v4().to_string();
-    build(
+    create_docker_image(
         "./examples/hello",
-        Some(name.clone()),
-        vec!["cowsay"],
-        None,
-        Some("./start.sh".to_string()),
-        false,
-        Vec::new(),
-        None,
-        None,
         Vec::new(),
-        Vec::new(),
-        true,
+        &GeneratePlanOptions {
+            pin_pkgs: true,
+            custom_start_cmd: Some("./start.sh".to_string()),
+            custom_pkgs: vec![Pkg::new("cowsay")],
+            ..Default::default()
+        },
+        &DockerBuilderOptions {
+            name: Some(name.clone()),
+            quiet: true,
+            ..Default::default()
+        },
     )
     .unwrap();
+
     let output = run_image(name);
     assert!(output.contains("Hello World"));
 }
diff --git a/tests/generate_plan_tests.rs b/tests/generate_plan_tests.rs
index 364ab12..f75e716 100644
--- a/tests/generate_plan_tests.rs
+++ b/tests/generate_plan_tests.rs
@@ -1,9 +1,19 @@
 use anyhow::Result;
-use nixpacks::{gen_plan, nixpacks::nix::Pkg};
+use nixpacks::{
+    generate_build_plan,
+    nixpacks::{
+        nix::pkg::Pkg,
+        plan::{generator::GeneratePlanOptions, BuildPlan},
+    },
+};
+
+fn simple_gen_plan(path: &str) -> BuildPlan {
+    generate_build_plan(path, Vec::new(), &GeneratePlanOptions::default()).unwrap()
+}
 
 #[test]
 fn test_node() -> Result<()> {
-    let plan = gen_plan("./examples/node", Vec::new(), None, None, Vec::new(), false)?;
+    let plan = simple_gen_plan("./examples/node");
     assert_eq!(plan.install.unwrap().cmd, Some("npm ci".to_string()));
     assert_eq!(plan.build.unwrap().cmd, None);
     assert_eq!(plan.start.unwrap().cmd, Some("npm run start".to_string()));
@@ -13,14 +23,7 @@ fn test_node() -> Result<()> {
 
 #[test]
 fn test_node_no_lockfile() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/node-no-lockfile",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/node-no-lockfile");
     assert_eq!(plan.install.unwrap().cmd, Some("npm i".to_string()));
     assert_eq!(plan.build.unwrap().cmd, None);
     assert_eq!(plan.start.unwrap().cmd, Some("npm run start".to_string()));
@@ -30,7 +33,7 @@ fn test_node_no_lockfile() -> Result<()> {
 
 #[test]
 fn test_npm() -> Result<()> {
-    let plan = gen_plan("./examples/npm", Vec::new(), None, None, Vec::new(), false)?;
+    let plan = simple_gen_plan("./examples/npm");
     assert_eq!(plan.build.unwrap().cmd, Some("npm run build".to_string()));
     assert_eq!(plan.start.unwrap().cmd, Some("npm run start".to_string()));
     assert_eq!(
@@ -47,14 +50,7 @@ fn test_npm() -> Result<()> {
 
 #[test]
 fn test_node_no_scripts() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/node-no-scripts",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/node-no-scripts");
     assert_eq!(plan.build.unwrap().cmd, None);
     assert_eq!(plan.start.unwrap().cmd, Some("node index.js".to_string()));
 
@@ -63,14 +59,7 @@ fn test_node_no_scripts() -> Result<()> {
 
 #[test]
 fn test_node_custom_version() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/node-custom-version",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/node-custom-version");
     assert_eq!(plan.setup.unwrap().pkgs, vec![Pkg::new("nodejs-18_x")]);
 
     Ok(())
@@ -78,7 +67,7 @@ fn test_node_custom_version() -> Result<()> {
 
 #[test]
 fn test_yarn() -> Result<()> {
-    let plan = gen_plan("./examples/yarn", Vec::new(), None, None, Vec::new(), false)?;
+    let plan = simple_gen_plan("./examples/yarn");
     assert_eq!(plan.build.unwrap().cmd, Some("yarn run build".to_string()));
     assert_eq!(plan.start.unwrap().cmd, Some("yarn run start".to_string()));
     assert_eq!(
@@ -95,14 +84,7 @@ fn test_yarn() -> Result<()> {
 
 #[test]
 fn test_yarn_berry() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/yarn-berry",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/yarn-berry");
     assert_eq!(
         plan.install.unwrap().cmd,
         Some("yarn set version berry && yarn install --immutable --check-cache".to_string())
@@ -112,14 +94,7 @@ fn test_yarn_berry() -> Result<()> {
 
 #[test]
 fn test_yarn_custom_version() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/yarn-custom-node-version",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/yarn-custom-node-version");
     assert_eq!(
         plan.setup.unwrap().pkgs,
         vec![
@@ -133,7 +108,7 @@ fn test_yarn_custom_version() -> Result<()> {
 
 #[test]
 fn pnpm() -> Result<()> {
-    let plan = gen_plan("./examples/pnpm", Vec::new(), None, None, Vec::new(), false)?;
+    let plan = simple_gen_plan("./examples/pnpm");
     assert_eq!(plan.build.unwrap().cmd, Some("pnpm run build".to_string()));
     assert_eq!(plan.start.unwrap().cmd, Some("pnpm run start".to_string()));
     assert_eq!(
@@ -150,14 +125,7 @@ fn pnpm() -> Result<()> {
 
 #[test]
 fn test_pnpm_custom_version() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/pnpm-custom-node-version",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/pnpm-custom-node-version");
     assert_eq!(
         plan.setup.unwrap().pkgs,
         vec![
@@ -171,7 +139,7 @@ fn test_pnpm_custom_version() -> Result<()> {
 
 #[test]
 fn test_go() -> Result<()> {
-    let plan = gen_plan("./examples/go", Vec::new(), None, None, Vec::new(), false)?;
+    let plan = simple_gen_plan("./examples/go");
     assert_eq!(
         plan.build.unwrap().cmd,
         Some("go build -o out main.go".to_string())
@@ -184,13 +152,10 @@ fn test_go() -> Result<()> {
 
 #[test]
 fn test_go_cgo_enabled() -> Result<()> {
-    let plan = gen_plan(
+    let plan = generate_build_plan(
         "./examples/go",
-        Vec::new(),
-        None,
-        None,
         vec!["CGO_ENABLED=1"],
-        false,
+        &GeneratePlanOptions::default(),
     )?;
     assert_eq!(
         plan.build.unwrap().cmd,
@@ -204,14 +169,7 @@ fn test_go_cgo_enabled() -> Result<()> {
 
 #[test]
 fn test_go_mod() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/go-mod",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/go-mod");
     assert_eq!(plan.build.unwrap().cmd, Some("go build -o out".to_string()));
     assert_eq!(plan.start.unwrap().cmd, Some("./out".to_string()));
 
@@ -220,14 +178,7 @@ fn test_go_mod() -> Result<()> {
 
 #[test]
 fn test_go_custom_version() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/go-custom-version",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/go-custom-version");
     assert_eq!(plan.setup.unwrap().pkgs, vec![Pkg::new("go_1_18")]);
 
     Ok(())
@@ -235,7 +186,7 @@ fn test_go_custom_version() -> Result<()> {
 
 #[test]
 fn test_deno() -> Result<()> {
-    let plan = gen_plan("./examples/deno", Vec::new(), None, None, Vec::new(), false)?;
+    let plan = simple_gen_plan("./examples/deno");
     assert_eq!(
         plan.build.unwrap().cmd,
         Some("deno cache src/index.ts".to_string())
@@ -250,14 +201,7 @@ fn test_deno() -> Result<()> {
 
 #[test]
 fn test_procfile() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/procfile",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/procfile");
     assert_eq!(plan.start.unwrap().cmd, Some("node index.js".to_string()));
 
     Ok(())
@@ -265,13 +209,14 @@ fn test_procfile() -> Result<()> {
 
 #[test]
 fn test_custom_pkgs() -> Result<()> {
-    let plan = gen_plan(
+    let plan = generate_build_plan(
         "./examples/hello",
-        vec!["cowsay"],
-        None,
-        Some("./start.sh".to_string()),
         Vec::new(),
-        false,
+        &GeneratePlanOptions {
+            custom_start_cmd: Some("./start.sh".to_string()),
+            custom_pkgs: vec![Pkg::new("cowsay")],
+            ..Default::default()
+        },
     )?;
     assert_eq!(plan.setup.unwrap().pkgs, vec![Pkg::new("cowsay")]);
     assert_eq!(plan.start.unwrap().cmd, Some("./start.sh".to_string()));
@@ -281,7 +226,14 @@ fn test_custom_pkgs() -> Result<()> {
 
 #[test]
 fn test_pin_archive() -> Result<()> {
-    let plan = gen_plan("./examples/hello", Vec::new(), None, None, Vec::new(), true)?;
+    let plan = generate_build_plan(
+        "./examples/hello",
+        Vec::new(),
+        &GeneratePlanOptions {
+            pin_pkgs: true,
+            ..Default::default()
+        },
+    )?;
     assert!(plan.setup.unwrap().archive.is_some());
 
     Ok(())
@@ -289,14 +241,7 @@ fn test_pin_archive() -> Result<()> {
 
 #[test]
 fn test_custom_rust_version() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/rust-custom-version",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/rust-custom-version");
     assert!(plan
         .build
         .unwrap()
@@ -318,14 +263,7 @@ fn test_custom_rust_version() -> Result<()> {
 
 #[test]
 fn test_rust_rocket() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/rust-rocket",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        true,
-    )?;
+    let plan = simple_gen_plan("./examples/rust-rocket");
     assert!(plan
         .build
         .unwrap()
@@ -344,13 +282,10 @@ fn test_rust_rocket() -> Result<()> {
 
 #[test]
 fn test_rust_rocket_no_musl() -> Result<()> {
-    let plan = gen_plan(
+    let plan = generate_build_plan(
         "./examples/rust-rocket",
-        Vec::new(),
-        None,
-        None,
         vec!["NIXPACKS_NO_MUSL=1"],
-        true,
+        &GeneratePlanOptions::default(),
     )?;
     assert_eq!(
         plan.build.unwrap().cmd,
@@ -370,14 +305,7 @@ fn test_rust_rocket_no_musl() -> Result<()> {
 
 #[test]
 pub fn test_python() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/python",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        true,
-    )?;
+    let plan = simple_gen_plan("./examples/python");
     assert_eq!(plan.build.unwrap().cmd, None);
     assert_eq!(
         plan.install.unwrap().cmd,
@@ -390,14 +318,7 @@ pub fn test_python() -> Result<()> {
 
 #[test]
 pub fn test_python_poetry() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/python-poetry",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        true,
-    )?;
+    let plan = simple_gen_plan("./examples/python-poetry");
     assert_eq!(plan.build.unwrap().cmd, None);
     assert_eq!(
         plan.install.unwrap().cmd,
@@ -410,14 +331,7 @@ pub fn test_python_poetry() -> Result<()> {
 
 #[test]
 fn test_node_main_file() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/node-main-file",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/node-main-file");
     assert_eq!(plan.build.unwrap().cmd, None);
     assert_eq!(
         plan.start.unwrap().cmd,
@@ -429,14 +343,7 @@ fn test_node_main_file() -> Result<()> {
 
 #[test]
 pub fn test_python_setuptools() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/python-setuptools",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        true,
-    )?;
+    let plan = simple_gen_plan("./examples/python-setuptools");
     assert_eq!(plan.build.unwrap().cmd, None);
 
     if let Some(install_cmd) = plan.install.unwrap().cmd {
@@ -456,14 +363,7 @@ pub fn test_python_setuptools() -> Result<()> {
 
 #[test]
 fn test_node_main_file_doesnt_exist() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/node-main-file-not-exist",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/node-main-file-not-exist");
     assert_eq!(plan.build.unwrap().cmd, None);
     assert_eq!(plan.start.unwrap().cmd, Some("node index.js".to_string()));
 
@@ -472,14 +372,7 @@ fn test_node_main_file_doesnt_exist() -> Result<()> {
 
 #[test]
 fn test_haskell_stack() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/haskell-stack",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/haskell-stack");
     assert_eq!(plan.build.unwrap().cmd, Some("stack build".to_string()));
     assert!(plan.start.unwrap().cmd.unwrap().contains("stack exec"));
     assert!(plan.install.unwrap().cmd.unwrap().contains("stack setup"));
@@ -488,14 +381,7 @@ fn test_haskell_stack() -> Result<()> {
 
 #[test]
 fn test_crystal() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/crystal",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
+    let plan = simple_gen_plan("./examples/crystal");
     assert_eq!(
         plan.install.unwrap().cmd,
         Some("shards install".to_string())
@@ -510,13 +396,10 @@ fn test_crystal() -> Result<()> {
 
 #[test]
 fn test_overriding_environment_variables() -> Result<()> {
-    let plan = gen_plan(
+    let plan = generate_build_plan(
         "./examples/variables",
-        Vec::new(),
-        None,
-        None,
         vec!["NODE_ENV=test"],
-        false,
+        &GeneratePlanOptions::default(),
     )?;
     assert_eq!(
         plan.variables.unwrap().get("NODE_ENV"),
@@ -528,18 +411,15 @@ fn test_overriding_environment_variables() -> Result<()> {
 
 #[test]
 fn test_config_from_environment_variables() -> Result<()> {
-    let plan = gen_plan(
+    let plan = generate_build_plan(
         "./examples/hello",
-        Vec::new(),
-        None,
-        None,
         vec![
             "NIXPACKS_PKGS=cowsay ripgrep",
             "NIXPACKS_BUILD_CMD=build",
             "NIXPACKS_START_CMD=start",
             "NIXPACKS_RUN_IMAGE=alpine",
         ],
-        false,
+        &GeneratePlanOptions::default(),
     )?;
     assert_eq!(plan.build.unwrap().cmd, Some("build".to_string()));
     assert_eq!(plan.start.clone().unwrap().cmd, Some("start".to_string()));