use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct DevboxConfig {
pub packages: Value,
pub env: BTreeMap<String, Value>,
pub shell: DevboxShell,
pub include: Vec<Value>,
pub env_from: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct DevboxShell {
pub init_hook: Value,
pub scripts: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DevboxPackage {
pub name: String,
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkippedItem {
pub what: String,
pub reason: String,
}
impl SkippedItem {
fn new(what: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
what: what.into(),
reason: reason.into(),
}
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct DevboxImport {
pub packages: Vec<DevboxPackage>,
pub env: BTreeMap<String, String>,
pub scripts: BTreeMap<String, String>,
pub init_hook: Vec<String>,
pub skipped: Vec<SkippedItem>,
}
impl DevboxImport {
pub fn is_empty(&self) -> bool {
self.packages.is_empty()
&& self.env.is_empty()
&& self.scripts.is_empty()
&& self.init_hook.is_empty()
}
}
pub fn parse(contents: &str) -> Result<DevboxImport> {
let config: DevboxConfig =
serde_json::from_str(contents).context("Failed to parse devbox.json")?;
normalize(config)
}
pub fn normalize(config: DevboxConfig) -> Result<DevboxImport> {
let mut skipped = Vec::new();
let packages = normalize_packages(&config.packages, &mut skipped)?;
let mut env = BTreeMap::new();
for (name, value) in &config.env {
match scalar_to_string(value) {
Some(v) => {
env.insert(name.clone(), v);
}
None => skipped.push(SkippedItem::new(
format!("env.{}", name),
"value is not a string, number or boolean",
)),
}
}
let mut scripts = BTreeMap::new();
for (name, body) in &config.shell.scripts {
let lines = to_lines(body);
if lines.is_empty() {
skipped.push(SkippedItem::new(
format!("shell.scripts.{}", name),
"script body is empty",
));
continue;
}
scripts.insert(name.clone(), lines.join("\n"));
}
let init_hook = to_lines(&config.shell.init_hook);
for entry in &config.include {
let label = entry.as_str().unwrap_or("<non-string>").to_string();
skipped.push(SkippedItem::new(
format!("include: {}", label),
"Devbox plugins have no flk equivalent — translate it by hand",
));
}
if let Some(env_from) = &config.env_from {
skipped.push(SkippedItem::new(
format!("env_from: {}", env_from),
"dotenv sourcing is not supported — add the variables with 'flk env add'",
));
}
for key in config.extra.keys() {
if key == "$schema" {
continue;
}
skipped.push(SkippedItem::new(
key.clone(),
"unrecognized devbox.json key",
));
}
Ok(DevboxImport {
packages,
env,
scripts,
init_hook,
skipped,
})
}
fn normalize_packages(value: &Value, skipped: &mut Vec<SkippedItem>) -> Result<Vec<DevboxPackage>> {
match value {
Value::Null => Ok(Vec::new()),
Value::Array(entries) => {
let mut packages = Vec::new();
for entry in entries {
match entry.as_str() {
Some(spec) => packages.push(split_spec(spec)),
None => skipped.push(SkippedItem::new(
entry.to_string(),
"package entry is not a string",
)),
}
}
Ok(packages)
}
Value::Object(entries) => {
let mut packages = Vec::new();
for (name, spec) in entries {
let version = match spec {
Value::String(v) => Some(v.clone()),
Value::Object(detail) => {
for filter in ["platforms", "excluded_platforms"] {
if detail.contains_key(filter) {
skipped.push(SkippedItem::new(
format!("packages.{}.{}", name, filter),
"platform filters are not supported — the package is imported for all systems",
));
}
}
detail
.get("version")
.and_then(Value::as_str)
.map(String::from)
}
other => {
skipped.push(SkippedItem::new(
format!("packages.{}", name),
format!("unsupported version spec: {}", other),
));
continue;
}
};
packages.push(DevboxPackage {
name: name.clone(),
version: version.filter(|v| !is_latest(v)),
});
}
Ok(packages)
}
other => anyhow::bail!(
"'packages' must be a list or an object, found: {}",
type_name(other)
),
}
}
fn split_spec(spec: &str) -> DevboxPackage {
match spec.split_once('@') {
Some((name, version)) if !name.is_empty() && !is_latest(version) => DevboxPackage {
name: name.to_string(),
version: Some(version.to_string()),
},
Some((name, _)) if !name.is_empty() => DevboxPackage {
name: name.to_string(),
version: None,
},
_ => DevboxPackage {
name: spec.to_string(),
version: None,
},
}
}
fn is_latest(version: &str) -> bool {
version.eq_ignore_ascii_case("latest")
}
fn to_lines(value: &Value) -> Vec<String> {
match value {
Value::String(s) => non_empty_lines(s),
Value::Array(entries) => entries
.iter()
.filter_map(Value::as_str)
.flat_map(non_empty_lines)
.collect(),
_ => Vec::new(),
}
}
fn non_empty_lines(s: &str) -> Vec<String> {
let lines: Vec<String> = s.lines().map(str::to_string).collect();
let first = lines.iter().position(|l| !l.trim().is_empty());
let last = lines.iter().rposition(|l| !l.trim().is_empty());
match (first, last) {
(Some(a), Some(b)) => lines[a..=b].to_vec(),
_ => Vec::new(),
}
}
fn scalar_to_string(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "a list",
Value::Object(_) => "an object",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, version: Option<&str>) -> DevboxPackage {
DevboxPackage {
name: name.to_string(),
version: version.map(String::from),
}
}
#[test]
fn parses_list_form_packages() {
let import = parse(r#"{"packages": ["ripgrep", "go@1.22"]}"#).unwrap();
assert_eq!(
import.packages,
vec![pkg("ripgrep", None), pkg("go", Some("1.22"))]
);
}
#[test]
fn map_form_packages_normalize_like_list_form() {
let from_map = parse(r#"{"packages": {"go": {"version": "1.22"}}}"#).unwrap();
let from_short_map = parse(r#"{"packages": {"go": "1.22"}}"#).unwrap();
let from_list = parse(r#"{"packages": ["go@1.22"]}"#).unwrap();
assert_eq!(from_map.packages, from_list.packages);
assert_eq!(from_short_map.packages, from_list.packages);
}
#[test]
fn latest_is_treated_as_unpinned() {
let import = parse(r#"{"packages": ["hello@latest", "world@LATEST"]}"#).unwrap();
assert_eq!(
import.packages,
vec![pkg("hello", None), pkg("world", None)]
);
}
#[test]
fn platform_filters_are_reported_not_applied() {
let import =
parse(r#"{"packages": {"go": {"version": "1.22", "platforms": ["x86_64-linux"]}}}"#)
.unwrap();
assert_eq!(import.packages, vec![pkg("go", Some("1.22"))]);
assert!(import
.skipped
.iter()
.any(|s| s.what == "packages.go.platforms"));
}
#[test]
fn init_hook_accepts_string_or_list() {
let as_list = parse(r#"{"shell": {"init_hook": ["echo one", "echo two"]}}"#).unwrap();
let as_string = parse(r#"{"shell": {"init_hook": "echo one\necho two"}}"#).unwrap();
assert_eq!(as_list.init_hook, vec!["echo one", "echo two"]);
assert_eq!(as_list.init_hook, as_string.init_hook);
}
#[test]
fn scripts_accept_string_or_list() {
let import = parse(
r#"{"shell": {"scripts": {
"build": ["cargo build", "cargo test"],
"fmt": "cargo fmt"
}}}"#,
)
.unwrap();
assert_eq!(import.scripts["build"], "cargo build\ncargo test");
assert_eq!(import.scripts["fmt"], "cargo fmt");
}
#[test]
fn empty_script_is_skipped_not_imported() {
let import = parse(r#"{"shell": {"scripts": {"noop": []}}}"#).unwrap();
assert!(import.scripts.is_empty());
assert!(import
.skipped
.iter()
.any(|s| s.what == "shell.scripts.noop"));
}
#[test]
fn env_values_coerce_scalars_to_strings() {
let import = parse(r#"{"env": {"PORT": 8080, "DEBUG": true, "NAME": "flk"}}"#).unwrap();
assert_eq!(import.env["PORT"], "8080");
assert_eq!(import.env["DEBUG"], "true");
assert_eq!(import.env["NAME"], "flk");
}
#[test]
fn include_and_env_from_are_reported_as_skipped() {
let import = parse(r#"{"include": ["plugin:nginx"], "env_from": ".env"}"#).unwrap();
assert!(import
.skipped
.iter()
.any(|s| s.what.contains("plugin:nginx")));
assert!(import.skipped.iter().any(|s| s.what.contains(".env")));
}
#[test]
fn unknown_keys_do_not_fail_the_parse_and_are_reported() {
let import = parse(r#"{"$schema": "https://x", "future_field": 1}"#).unwrap();
assert!(import.skipped.iter().any(|s| s.what == "future_field"));
assert!(
!import.skipped.iter().any(|s| s.what == "$schema"),
"$schema is editor metadata and should not be reported"
);
}
#[test]
fn malformed_json_reports_the_file() {
let err = parse("{ not json").unwrap_err();
assert!(err.to_string().contains("devbox.json"));
}
#[test]
fn packages_of_wrong_type_is_an_error() {
let err = parse(r#"{"packages": "ripgrep"}"#).unwrap_err();
assert!(err.to_string().contains("must be a list or an object"));
}
#[test]
fn empty_manifest_is_empty() {
assert!(parse("{}").unwrap().is_empty());
}
}