use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use crate::services::BUILTIN_SERVICES;
use crate::services::all_reserved_names;
pub use crate::services::garage::GarageConfig;
#[allow(unused_imports)]
pub use crate::services::postgres::NeonProxyConfig;
pub use crate::services::postgres::PostgresConfig;
pub const CONFIG_FILE_NAME: &str = "ai-igniter.toml";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compose_file: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dev_command: Option<String>,
pub env_file: PathBuf,
pub copy_files: Vec<CopyFileRule>,
#[serde(default)]
pub services: ServicesConfig,
#[serde(default)]
pub env_template: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CopyFileRule {
pub from: PathBuf,
pub to: PathBuf,
}
impl<'de> Deserialize<'de> for CopyFileRule {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Rule {
SamePath(PathBuf),
Rename { from: PathBuf, to: PathBuf },
}
match Rule::deserialize(deserializer)? {
Rule::SamePath(path) => Ok(Self {
from: path.clone(),
to: path,
}),
Rule::Rename { from, to } => Ok(Self { from, to }),
}
}
}
pub fn merge_toml_values(base: &mut toml::Value, overrides: toml::Value) {
match (base, overrides) {
(toml::Value::Table(base_table), toml::Value::Table(override_table)) => {
for (key, val) in override_table {
match base_table.get_mut(&key) {
Some(base_val) => merge_toml_values(base_val, val),
None => {
base_table.insert(key, val);
}
}
}
}
(base_val, override_val) => {
*base_val = override_val;
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServicesConfig {
pub postgres: Option<PostgresConfig>,
pub garage: Option<GarageConfig>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub custom: BTreeMap<String, CustomServiceConfig>,
}
impl ServicesConfig {
#[allow(dead_code)]
pub fn postgres(&self) -> Option<&PostgresConfig> {
self.postgres.as_ref().filter(|c| c.enabled)
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn garage(&self) -> Option<&GarageConfig> {
self.garage.as_ref().filter(|c| c.enabled)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomServiceConfig {
pub image: String,
pub port_offset: Option<u16>,
pub target_port: Option<u16>,
#[serde(default)]
pub environment: BTreeMap<String, String>,
#[serde(default)]
pub command: Vec<String>,
#[serde(default)]
pub volumes: Vec<String>,
}
impl Config {
pub fn env_file(&self) -> &Path {
self.env_file.as_path()
}
#[allow(dead_code)]
pub fn load_from_file(path: &Path) -> Result<Self> {
Self::load_from_file_with_override(path, None)
}
pub fn load_from_file_with_override(path: &Path, override_path: Option<&Path>) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file at {:?}", path))?;
let mut base_value: toml::Value = toml::from_str(&content)
.with_context(|| format!("Failed to parse TOML in {:?}", path))?;
if let Some(ov_path) = override_path {
let ov_content = std::fs::read_to_string(ov_path)
.with_context(|| format!("Failed to read local override file at {:?}", ov_path))?;
let ov_value: toml::Value = toml::from_str(&ov_content)
.with_context(|| format!("Failed to parse local TOML override in {:?}", ov_path))?;
merge_toml_values(&mut base_value, ov_value);
}
let config: Config = base_value.try_into().with_context(|| {
if let Some(ov_path) = override_path {
format!(
"Invalid configuration in {:?} after applying local override from {:?}",
path, ov_path
)
} else {
format!("Invalid configuration in {:?}", path)
}
})?;
Ok(config)
}
pub fn find_local_override(workspace_path: &Path, root_path: &Path) -> Option<PathBuf> {
let candidates = [
workspace_path.join("ai-igniter.local.toml"),
workspace_path.join(".ai-igniter.local.toml"),
root_path.join("ai-igniter.local.toml"),
root_path.join(".ai-igniter.local.toml"),
];
for candidate in &candidates {
if candidate.exists() {
return Some(candidate.clone());
}
}
None
}
pub fn find_local_override_for_path(
config_path: &Path,
workspace_path: &Path,
root_path: Option<&Path>,
) -> Option<PathBuf> {
if let Some(file_name) = config_path.file_name().and_then(|n| n.to_str()) {
let file_stem = config_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(file_name);
let local_adjacent = config_path.with_file_name(format!("{file_stem}.local.toml"));
if local_adjacent.exists() {
return Some(local_adjacent);
}
let dot_local_adjacent = config_path.with_file_name(format!(".{file_stem}.local.toml"));
if dot_local_adjacent.exists() {
return Some(dot_local_adjacent);
}
}
Self::find_local_override(workspace_path, root_path.unwrap_or(workspace_path))
}
pub fn find_config(workspace_path: &Path, root_path: &Path) -> Result<(PathBuf, Self)> {
let candidates = [
workspace_path.join(CONFIG_FILE_NAME),
workspace_path.join(format!(".{}", CONFIG_FILE_NAME)),
root_path.join(CONFIG_FILE_NAME),
root_path.join(format!(".{}", CONFIG_FILE_NAME)),
];
let local_override = Self::find_local_override(workspace_path, root_path);
for candidate in &candidates {
if candidate.exists() {
let cfg = Self::load_from_file_with_override(candidate, local_override.as_deref())?;
return Ok((candidate.clone(), cfg));
}
}
anyhow::bail!(
"Configuration file '{}' not found in workspace ({:?}) or root ({:?}). Run `ai-igniter init` to create one.",
CONFIG_FILE_NAME,
workspace_path,
root_path
)
}
pub fn port_offsets(&self) -> Vec<(String, u16)> {
let mut offsets = Vec::new();
for provider in BUILTIN_SERVICES {
offsets.extend(provider.port_offsets(&self.services));
}
for (name, custom) in &self.services.custom {
if let Some(offset) = custom.port_offset {
offsets.push((name.clone(), offset));
}
}
offsets
}
pub fn validate(&self) -> Result<()> {
if sanitize_name(&self.name).is_empty() {
bail!("`name` must contain at least one ASCII letter or digit");
}
validate_relative_file_path("`env_file`", self.env_file())?;
let mut destinations = HashSet::new();
for rule in &self.copy_files {
validate_relative_file_path("`copy_files.from`", &rule.from)?;
validate_relative_file_path("`copy_files.to`", &rule.to)?;
if !destinations.insert(rule.to.clone()) {
bail!(
"`copy_files` contains more than one rule for destination {:?}",
rule.to
);
}
}
let reserved = all_reserved_names();
for (name, custom) in &self.services.custom {
let valid = name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
if !valid {
bail!("Custom service name '{name}' must match [a-z0-9][a-z0-9_-]*");
}
if reserved.contains(&name.as_str()) {
bail!("Custom service name '{name}' is reserved");
}
if custom.port_offset.is_some() != custom.target_port.is_some() {
bail!(
"Custom service '{name}': `port_offset` and `target_port` must be set together"
);
}
}
let offsets = self.port_offsets();
let mut seen: HashMap<u16, &str> = HashMap::new();
for (name, offset) in &offsets {
if *offset == 0 {
bail!(
"Service '{name}' has port offset 0, which collides with the base (app) port"
);
}
if let Some(other) = seen.insert(*offset, name) {
bail!("Services '{other}' and '{name}' share port offset {offset}");
}
}
Ok(())
}
}
fn validate_relative_file_path(field: &str, path: &Path) -> Result<()> {
if path.as_os_str().is_empty() {
bail!("{field} must not be empty");
}
for component in path.components() {
match component {
Component::Normal(_) => {}
Component::CurDir => bail!("{field} must name a file, not the workspace directory"),
Component::ParentDir => bail!("{field} must not contain `..`: {:?}", path),
Component::RootDir | Component::Prefix(_) => {
bail!("{field} must be relative: {:?}", path)
}
}
}
Ok(())
}
pub fn sanitize_name(raw: &str) -> String {
raw.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches(|c| c == '-' || c == '_')
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(toml_str: &str) -> Config {
let mut prefix = String::new();
if !toml_str.contains("env_file") {
prefix.push_str("env_file = \".env\"\n");
}
if !toml_str.contains("copy_files") {
prefix.push_str("copy_files = []\n");
}
let doc = format!("{prefix}{toml_str}");
toml::from_str(&doc).unwrap()
}
#[test]
fn base_port_is_optional() {
assert_eq!(parse("name = \"my-project\"").base_port, None);
assert_eq!(
parse("name = \"my-project\"\nbase_port = 4000").base_port,
Some(4000)
);
}
#[test]
fn base_port_serialized_only_when_set() {
let mut config = parse("name = \"my-project\"");
assert!(
!toml::to_string_pretty(&config)
.unwrap()
.contains("base_port")
);
config.base_port = Some(4500);
assert!(
toml::to_string_pretty(&config)
.unwrap()
.contains("base_port = 4500")
);
}
#[test]
fn dev_command_is_optional_and_serializes_when_set() {
let config = parse("name = \"my-project\"");
assert_eq!(config.dev_command, None);
assert!(
!toml::to_string_pretty(&config)
.unwrap()
.contains("dev_command")
);
let with_cmd = parse("name = \"my-project\"\ndev_command = \"bun run dev\"");
assert_eq!(with_cmd.dev_command.as_deref(), Some("bun run dev"));
assert!(
toml::to_string_pretty(&with_cmd)
.unwrap()
.contains("dev_command = \"bun run dev\"")
);
}
#[test]
fn parses_copy_file_string_syntax() {
let config = parse("name = \"p\"\ncopy_files = [\".env\"]");
assert_eq!(
config.copy_files,
vec![CopyFileRule {
from: PathBuf::from(".env"),
to: PathBuf::from(".env"),
}]
);
}
#[test]
fn parses_copy_file_object_and_mixed_syntax() {
let config = parse(
r#"
name = "p"
copy_files = [".env.test", { from = ".env", to = ".env.local" }]
"#,
);
assert_eq!(
config.copy_files,
vec![
CopyFileRule {
from: PathBuf::from(".env.test"),
to: PathBuf::from(".env.test"),
},
CopyFileRule {
from: PathBuf::from(".env"),
to: PathBuf::from(".env.local"),
},
]
);
}
#[test]
fn copy_files_and_env_file_are_mandatory() {
assert!(toml::from_str::<Config>("name = \"p\"").is_err());
assert!(toml::from_str::<Config>("name = \"p\"\nenv_file = \".env\"").is_err());
assert!(toml::from_str::<Config>("name = \"p\"\ncopy_files = []").is_err());
assert!(
toml::from_str::<Config>("name = \"p\"\nenv_file = \".env\"\ncopy_files = []").is_ok()
);
}
#[test]
fn env_file_serializes_and_reads_correctly() {
let config = parse("name = \"p\"\nenv_file = \".env.local\"");
assert_eq!(config.env_file(), Path::new(".env.local"));
assert!(
toml::to_string_pretty(&config)
.unwrap()
.contains("env_file = \".env.local\"")
);
}
#[test]
fn rejects_unsafe_file_paths_and_duplicate_destinations() {
for setting in [
"env_file = \"../outside\"",
"env_file = \"/outside\"",
"env_file = \"\"",
"copy_files = [{ from = \"../outside\", to = \".env\" }]",
"copy_files = [{ from = \".env\", to = \"../outside\" }]",
] {
let config = parse(&format!("name = \"p\"\n{setting}"));
assert!(config.validate().is_err(), "{setting}");
}
let duplicate = parse(
r#"
name = "p"
copy_files = [
".env.local",
{ from = ".env", to = ".env.local" },
]
"#,
);
assert!(duplicate.validate().is_err());
}
#[test]
fn unknown_sections_are_ignored() {
let config = parse(
"name = \"p\"\n[orchestrator]\nport_env = \"PASEO_PORT\"\nworkspace_env = \"PASEO_WORKTREE_PATH\"",
);
assert_eq!(config.name, "p");
}
#[test]
fn rejects_duplicate_offsets() {
let config = parse(
r#"
name = "p"
[services.postgres]
database = "p"
user = "p"
password = "p"
[services.garage]
access_key = "k"
secret_key = "s"
port_offset = 1
"#,
);
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("share port offset 1"), "{err}");
}
#[test]
fn disabled_services_do_not_reserve_offsets() {
let config = parse(
r#"
name = "p"
[services.garage]
enabled = false
access_key = "k"
secret_key = "s"
port_offset = 1
[services.custom.mail]
image = "axllent/mailpit"
port_offset = 1
target_port = 8025
"#,
);
config.validate().unwrap();
}
#[test]
fn rejects_zero_offset_and_reserved_custom_names() {
let zero = parse(
"name = \"p\"\n[services.postgres]\ndatabase = \"p\"\nuser = \"p\"\npassword = \"p\"\nport_offset = 0",
);
assert!(zero.validate().is_err());
let reserved = parse("name = \"p\"\n[services.custom.postgres]\nimage = \"x\"");
assert!(reserved.validate().is_err());
let half_port =
parse("name = \"p\"\n[services.custom.mail]\nimage = \"x\"\nport_offset = 7");
assert!(half_port.validate().is_err());
}
#[test]
fn sanitizes_names_for_compose() {
assert_eq!(sanitize_name("My App"), "my-app");
assert_eq!(sanitize_name("_ai.tools-"), "ai-tools");
assert_eq!(sanitize_name("éé"), "");
}
#[test]
fn all_buckets_dedupes_website_buckets() {
let config = parse(
r#"
name = "p"
[services.garage]
access_key = "k"
secret_key = "s"
buckets = ["assets", "uploads"]
website_buckets = ["assets", "site"]
"#,
);
assert_eq!(
config.services.garage().unwrap().all_buckets(),
["assets", "uploads", "site"]
);
}
#[test]
fn postgres_neon_proxy_reserves_offset_and_names() {
let config = parse(
r#"
name = "p"
[services.postgres]
database = "p"
user = "u"
password = "p"
neon_proxy = true
[services.custom.app2]
image = "myimage"
port_offset = 2
target_port = 8080
"#,
);
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("share port offset 2"), "{err}");
let reserved_name = parse(
r#"
name = "p"
[services.custom.neon-proxy]
image = "myimage"
"#,
);
assert!(reserved_name.validate().is_err());
}
#[test]
fn test_merge_toml_values() {
let base_str = r#"
name = "base-app"
env_file = ".env"
copy_files = []
base_port = 3000
[env_template]
VAR_A = "1"
VAR_B = "2"
"#;
let override_str = r#"
base_port = 4000
[env_template]
VAR_B = "overridden"
VAR_C = "3"
"#;
let mut base_val: toml::Value = toml::from_str(base_str).unwrap();
let override_val: toml::Value = toml::from_str(override_str).unwrap();
merge_toml_values(&mut base_val, override_val);
let merged: Config = base_val.try_into().unwrap();
assert_eq!(merged.name, "base-app");
assert_eq!(merged.base_port, Some(4000));
assert_eq!(
merged.env_template.get("VAR_A").map(|s| s.as_str()),
Some("1")
);
assert_eq!(
merged.env_template.get("VAR_B").map(|s| s.as_str()),
Some("overridden")
);
assert_eq!(
merged.env_template.get("VAR_C").map(|s| s.as_str()),
Some("3")
);
}
}