use anyhow::{anyhow, bail, Context, Result};
use clap::Subcommand;
use colored::Colorize;
use std::fs;
use std::path::{Path, PathBuf};
use crate::commands::{add, command, env, init};
use flk::devbox::{self, DevboxImport};
use flk::flake::parsers::commands::parse_shell_hook_section;
use flk::flake::parsers::env::parse_env_vars_section;
use flk::flake::parsers::shellhook::append_to_shell_hook;
use flk::flake::parsers::utils::resolve_profile;
const DEVBOX_MANIFEST: &str = "devbox.json";
#[derive(Subcommand)]
pub enum ImportSource {
Devbox {
#[arg(short, long)]
file: Option<String>,
#[arg(short = 'p', long)]
profile: Option<String>,
#[arg(long)]
dry_run: bool,
},
}
pub fn run(source: ImportSource) -> Result<()> {
match source {
ImportSource::Devbox {
file,
profile,
dry_run,
} => run_devbox(file, profile, dry_run),
}
}
pub fn run_devbox(file: Option<String>, profile: Option<String>, dry_run: bool) -> Result<()> {
let manifest = resolve_manifest(file)?;
println!(
"{} Reading {}",
"→".blue().bold(),
manifest.display().to_string().cyan()
);
let contents = fs::read_to_string(&manifest)
.with_context(|| format!("Failed to read '{}'", manifest.display()))?;
let import = devbox::parse(&contents)
.with_context(|| format!("Invalid devbox manifest: '{}'", manifest.display()))?;
if import.is_empty() {
println!(
"{} Nothing to import — the manifest declares no packages, variables or scripts.",
"ℹ".blue()
);
report_skipped(&import);
return Ok(());
}
if dry_run {
print_plan(&import, profile.as_deref().unwrap_or("<default>"));
report_skipped(&import);
println!("\n{} Dry run — nothing was written.", "ℹ".blue());
return Ok(());
}
if !Path::new("flake.nix").exists() {
println!(
"{} No flake.nix found — initializing an flk project first.\n",
"→".blue().bold()
);
init::run(None, false, false)?;
println!();
}
let profile = resolve_profile(profile)?;
let profile_path = profile_path(&profile);
if !profile_path.exists() {
bail!(
"Profile '{}' does not exist ({} not found). Create it with {}.",
profile.yellow(),
profile_path.display(),
format!("flk profile add {}", profile).cyan()
);
}
println!(
"{} Importing into profile {}\n",
"→".blue().bold(),
profile.green()
);
let mut failures: Vec<String> = Vec::new();
let mut imported = Totals::default();
import_packages(&import, &profile, &mut imported, &mut failures);
import_env_vars(
&import,
&profile,
&profile_path,
&mut imported,
&mut failures,
);
import_scripts(
&import,
&profile,
&profile_path,
&mut imported,
&mut failures,
);
import_init_hook(&import, &profile_path, &mut imported, &mut failures);
print_summary(&imported);
report_skipped(&import);
if !failures.is_empty() {
println!("\n{} Failed to import:", "✗".red().bold());
for failure in &failures {
println!(" - {}", failure);
}
return Err(anyhow!(
"{} of the manifest's entries could not be imported",
failures.len()
));
}
println!("\n{}", "Next steps:".bold());
println!(" 1. Review {}", profile_path.display().to_string().cyan());
println!(
" 2. Run {} to enter the environment",
"flk activate".cyan()
);
Ok(())
}
#[derive(Default)]
struct Totals {
packages: usize,
env_vars: usize,
scripts: usize,
hook_lines: usize,
}
fn resolve_manifest(file: Option<String>) -> Result<PathBuf> {
match file {
Some(path) => {
let path = PathBuf::from(path);
if !path.exists() {
bail!("Manifest '{}' does not exist.", path.display());
}
Ok(path)
}
None => {
let default = PathBuf::from(DEVBOX_MANIFEST);
if !default.exists() {
bail!(
"No {} in the current directory. Pass {} to import one from elsewhere.",
DEVBOX_MANIFEST.yellow(),
"--file <PATH>".cyan()
);
}
Ok(default)
}
}
}
fn profile_path(profile: &str) -> PathBuf {
Path::new(".flk/profiles").join(format!("{}.nix", profile))
}
fn import_packages(
import: &DevboxImport,
profile: &str,
imported: &mut Totals,
failures: &mut Vec<String>,
) {
for package in &import.packages {
let label = match &package.version {
Some(version) => format!("{}@{}", package.name, version),
None => package.name.clone(),
};
match add::run_add(
&package.name,
package.version.clone(),
Some(profile.to_string()),
) {
Ok(()) => imported.packages += 1,
Err(e) => failures.push(format!("package {}: {}", label.yellow(), e)),
}
}
}
fn import_env_vars(
import: &DevboxImport,
profile: &str,
profile_path: &Path,
imported: &mut Totals,
failures: &mut Vec<String>,
) {
for (name, value) in &import.env {
if value.contains("{{") {
println!(
"{} {} uses a Devbox template variable ({}); it will not expand and needs editing by hand.",
"âš ".yellow().bold(),
name.cyan(),
value.dimmed()
);
}
match env_var_declared(profile_path, name) {
Ok(true) => {
println!(
"{} Skipping {} — already declared in this profile.",
"âš ".yellow().bold(),
name.cyan()
);
continue;
}
Ok(false) => {}
Err(e) => {
failures.push(format!("env {}: {}", name.yellow(), e));
continue;
}
}
match env::add(name, value, Some(profile.to_string())) {
Ok(()) => imported.env_vars += 1,
Err(e) => failures.push(format!("env {}: {}", name.yellow(), e)),
}
}
}
fn import_scripts(
import: &DevboxImport,
profile: &str,
profile_path: &Path,
imported: &mut Totals,
failures: &mut Vec<String>,
) {
for (name, script) in &import.scripts {
match command_declared(profile_path, name) {
Ok(true) => {
println!(
"{} Skipping command {} — already declared in this profile.",
"âš ".yellow().bold(),
name.cyan()
);
continue;
}
Ok(false) => {}
Err(e) => {
failures.push(format!("script {}: {}", name.yellow(), e));
continue;
}
}
match command::run_add(name, script, None, Some(profile.to_string())) {
Ok(()) => imported.scripts += 1,
Err(e) => failures.push(format!("script {}: {}", name.yellow(), e)),
}
}
}
fn import_init_hook(
import: &DevboxImport,
profile_path: &Path,
imported: &mut Totals,
failures: &mut Vec<String>,
) {
if import.init_hook.is_empty() {
return;
}
println!(
"{} Appending {} init_hook line(s) to shellHook",
"→".blue().bold(),
import.init_hook.len().to_string().green()
);
let result = fs::read_to_string(profile_path)
.with_context(|| format!("Failed to read '{}'", profile_path.display()))
.and_then(|content| append_to_shell_hook(&content, &import.init_hook))
.and_then(|updated| {
fs::write(profile_path, updated)
.with_context(|| format!("Failed to write '{}'", profile_path.display()))
});
match result {
Ok(()) => imported.hook_lines = import.init_hook.len(),
Err(e) => failures.push(format!("{}: {}", "init_hook".yellow(), e)),
}
}
fn env_var_declared(profile_path: &Path, name: &str) -> Result<bool> {
let content = fs::read_to_string(profile_path)
.with_context(|| format!("Failed to read '{}'", profile_path.display()))?;
parse_env_vars_section(&content)?.env_var_exists(name)
}
fn command_declared(profile_path: &Path, name: &str) -> Result<bool> {
let content = fs::read_to_string(profile_path)
.with_context(|| format!("Failed to read '{}'", profile_path.display()))?;
Ok(parse_shell_hook_section(&content)?.command_exists(name))
}
fn print_plan(import: &DevboxImport, profile: &str) {
println!(
"\n{} {}\n",
"Would import into profile".bold(),
profile.green()
);
if !import.packages.is_empty() {
println!("{}", "Packages".bold());
for package in &import.packages {
match &package.version {
Some(version) => println!(
" {} → pkgs.\"{}@{}\" (pinned)",
package.name.green(),
package.name,
version
),
None => println!(" {} → pkgs.{}", package.name.green(), package.name),
}
}
println!();
}
if !import.env.is_empty() {
println!("{}", "Environment variables".bold());
for (name, value) in &import.env {
println!(" {} = {}", name.cyan(), value);
}
println!();
}
if !import.scripts.is_empty() {
println!("{}", "Commands".bold());
for (name, script) in &import.scripts {
let first = script.lines().next().unwrap_or_default();
let suffix = if script.lines().count() > 1 {
" …"
} else {
""
};
println!(" {} → {}{}", name.cyan(), first.dimmed(), suffix);
}
println!();
}
if !import.init_hook.is_empty() {
println!("{}", "shellHook additions".bold());
for line in &import.init_hook {
println!(" {}", line.dimmed());
}
println!();
}
}
fn print_summary(imported: &Totals) {
println!("\n{} Imported:", "✓".green().bold());
println!(" {} package(s)", imported.packages.to_string().green());
println!(
" {} environment variable(s)",
imported.env_vars.to_string().green()
);
println!(" {} command(s)", imported.scripts.to_string().green());
println!(
" {} shellHook line(s)",
imported.hook_lines.to_string().green()
);
}
fn report_skipped(import: &DevboxImport) {
if import.skipped.is_empty() {
return;
}
println!("\n{} Not imported:", "âš ".yellow().bold());
for item in &import.skipped {
println!(" - {} — {}", item.what.yellow(), item.reason);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::cwd_test_guard;
use std::env;
use tempfile::TempDir;
const PROFILE: &str = "generic";
fn setup_project() -> TempDir {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join(".flk/profiles")).unwrap();
fs::write(root.join("flake.nix"), "{ outputs = _: {}; }\n").unwrap();
fs::write(
root.join(".flk/config.nix"),
"{\n defaultProfile = \"generic\";\n maxCombinations = 3;\n}\n",
)
.unwrap();
fs::write(
root.join(".flk/pins.nix"),
"{\n sources = {\n };\n\n pinnedPackages = {\n };\n}\n",
)
.unwrap();
fs::write(
root.join(format!(".flk/profiles/{}.nix", PROFILE)),
concat!(
"{pkgs, ...}: {\n",
" packages = [\n",
" ];\n\n",
" envVars = {\n",
" };\n\n",
" commands = [];\n\n",
" shellHook = ''\n",
" echo \"ready\"\n",
" '';\n",
"}\n"
),
)
.unwrap();
env::set_current_dir(root).unwrap();
temp
}
fn profile_contents() -> String {
fs::read_to_string(format!(".flk/profiles/{}.nix", PROFILE)).unwrap()
}
#[test]
fn imports_env_scripts_and_init_hook() {
let _guard = cwd_test_guard();
let _temp = setup_project();
fs::write(
"devbox.json",
r#"{
"env": { "GOFLAGS": "-mod=vendor" },
"shell": {
"init_hook": ["echo entering"],
"scripts": { "build": ["go build ./..."] }
}
}"#,
)
.unwrap();
run_devbox(None, Some(PROFILE.into()), false).unwrap();
let profile = profile_contents();
assert!(profile.contains("GOFLAGS"));
assert!(profile.contains("-mod=vendor"));
assert!(profile.contains("build"));
assert!(profile.contains("go build ./..."));
assert!(profile.contains("echo entering"));
assert!(profile.contains("echo \"ready\""));
}
#[test]
fn dry_run_writes_nothing() {
let _guard = cwd_test_guard();
let _temp = setup_project();
fs::write(
"devbox.json",
r#"{"env": {"FOO": "bar"}, "shell": {"scripts": {"hi": "echo hi"}}}"#,
)
.unwrap();
let before = profile_contents();
run_devbox(None, None, true).unwrap();
assert_eq!(profile_contents(), before);
}
#[test]
fn existing_declarations_are_skipped_not_failed() {
let _guard = cwd_test_guard();
let _temp = setup_project();
fs::write("devbox.json", r#"{"env": {"FOO": "second"}}"#).unwrap();
crate::commands::env::add("FOO", "first", Some(PROFILE.into())).unwrap();
run_devbox(None, Some(PROFILE.into()), false).unwrap();
let profile = profile_contents();
assert!(profile.contains("\"first\""));
assert!(!profile.contains("\"second\""));
}
#[test]
fn missing_manifest_is_an_error() {
let _guard = cwd_test_guard();
let _temp = setup_project();
let err = run_devbox(None, None, false).unwrap_err();
assert!(err.to_string().contains("devbox.json"));
}
#[test]
fn explicit_missing_file_is_an_error() {
let _guard = cwd_test_guard();
let _temp = setup_project();
let err = run_devbox(Some("nope.json".into()), None, false).unwrap_err();
assert!(err.to_string().contains("nope.json"));
}
#[test]
fn untranslatable_fields_do_not_fail_the_import() {
let _guard = cwd_test_guard();
let _temp = setup_project();
fs::write(
"devbox.json",
r#"{"include": ["plugin:nginx"], "env_from": ".env", "env": {"A": "b"}}"#,
)
.unwrap();
run_devbox(None, Some(PROFILE.into()), false).unwrap();
assert!(profile_contents().contains("A = \"b\""));
}
}