use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::error::Result;
pub const CONFIG_FILE: &str = ".partiri.jsonc";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PartiriConfig {
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deploy_tag: Option<String>,
pub fk_workspace: String,
pub fk_project: String,
pub service: ServiceConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceConfig {
pub name: String,
pub deploy_type: String,
pub runtime: String,
pub root_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub repository_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repository_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registry_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registry_repository_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fk_service_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub build_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub build_command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pre_deploy_command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_command: Option<String>,
pub fk_region: String,
pub fk_pod: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub health_check_path: Option<String>,
pub maintenance_mode: bool,
pub active: bool,
#[serde(default)]
pub env: Vec<EnvVar>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvVar {
pub key: String,
pub value: String,
}
impl PartiriConfig {
pub fn load() -> Result<Self> {
let path = Self::config_path();
if !path.exists() {
return Err(Box::new(
crate::error::CliError::new(
"config",
format!("No {} found in the current directory.", CONFIG_FILE),
)
.with_hint("Run 'partiri init --template' to create one.")
.enriched(),
));
}
let content = std::fs::read_to_string(&path).map_err(|e| {
Box::new(
crate::error::CliError::new("config", format!("Failed to read {CONFIG_FILE}: {e}"))
.enriched(),
) as crate::error::Error
})?;
let config: Self = json5::from_str(&content).map_err(|e| {
Box::new(
crate::error::CliError::new(
"config",
format!("Failed to parse {CONFIG_FILE}: {e}"),
)
.with_hint("Run 'partiri validate' to see which fields are wrong.")
.enriched(),
) as crate::error::Error
})?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path();
let data = self
.to_jsonc_string()
.map_err(|e| format!("Failed to serialize config: {e}"))?;
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.map_err(|e| format!("Failed to write {CONFIG_FILE}: {e}"))?;
file.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write {CONFIG_FILE}: {e}"))?;
}
#[cfg(not(unix))]
{
std::fs::write(&path, data)
.map_err(|e| format!("Failed to write {CONFIG_FILE}: {e}"))?;
}
Ok(())
}
pub fn config_path() -> PathBuf {
PathBuf::from(CONFIG_FILE)
}
pub fn id_or_err(&self) -> Result<&str> {
self.id
.as_deref()
.ok_or_else(|| "Service not yet created. Run 'partiri service create' first.".into())
}
pub fn to_jsonc_string(&self) -> Result<String> {
let svc = &self.service;
let env_json = serde_json::to_string_pretty(&svc.env)?;
let repo_section = if svc.repository_url.is_some() {
format!(
r#"
// ─── Repository source ─────────────────────────────────────────────────────
// Required for deploy_type "static" (registry not supported for static).
"repository_url": {},
"repository_branch": {},
// "registry_url": null, // not used when deploying from a repository
// "registry_repository_url": null,"#,
json_opt_str(&svc.repository_url),
json_opt_str(&svc.repository_branch),
)
} else {
format!(
r#"
// ─── Registry source (not available for deploy_type "static") ──────────────
"registry_url": {},
"registry_repository_url": {},
// "repository_url": null, // not used when deploying from a registry
// "repository_branch": null,"#,
json_opt_str(&svc.registry_url),
json_opt_str(&svc.registry_repository_url),
)
};
let secret_line = match &svc.fk_service_secret {
Some(id) => format!(
r#" // Authentication token for private repository / registry access.
"fk_service_secret": {},"#,
json_str(id)
),
None => r#" // Authentication token for private repository / registry access.
// "fk_service_secret": "uuid", // run 'partiri service token' to configure"#
.to_string(),
};
let health_section = format!(
r#"
// Health check path (GET). Set to null to disable.
"health_check_path": {},"#,
json_opt_str(&svc.health_check_path)
);
let pre_deploy = match &svc.pre_deploy_command {
Some(cmd) => format!(r#" "pre_deploy_command": {},"#, json_str(cmd)),
None => r#" // "pre_deploy_command": "", // optional: runs before each deploy (e.g. migrations)"#.to_string(),
};
let build_path = match &svc.build_path {
Some(p) => format!(r#" "build_path": {},"#, json_str(p)),
None => r#" // "build_path": "dist", // output directory of the build step"#
.to_string(),
};
Ok(format!(
r#"{{
// The service ID assigned by Partiri after running 'partiri service create'.
// Leave as null until you have created the service.
"id": {},
// Set by Partiri after each deployment. Required for 'partiri service logs' and metrics.
// Run 'partiri service pull' to refresh this value after a new deployment.
"deploy_tag": {},
// The workspace this service belongs to (selected during init).
"fk_workspace": {},
// The project this service belongs to (selected during init).
"fk_project": {},
"service": {{
// Display name for your service on Partiri Cloud.
"name": {},
// Service type. Supported values: "webservice" | "static" | "private-service"
// - webservice: public HTTP service with an external URL
// - static: static file hosting (repository only — registry not supported)
// - private-service: internal HTTP service, not publicly accessible
"deploy_type": {},
// Runtime environment. Supported: "node" | "rust" | "python" | "go" | "ruby" | "elixir" | "php" | "jvm" | "dotnet" | "cpp" | "static" | "registry"
"runtime": {},
// Path to the root of your application within the repository.
"root_path": {},
{}
{}
// Command to build the project (leave empty if not needed).
"build_command": {},
{}
{}
// Command to start the service at runtime.
"run_command": {},
// Region where the service will be deployed.
"fk_region": {},
// Compute pod — determines CPU and RAM allocated to the service.
"fk_pod": {},
{}
// Enable maintenance mode (serves a maintenance page instead of the app).
"maintenance_mode": {},
// Whether the service is active.
"active": {},
// Environment variables injected into the service at runtime.
// Format: [{{ "key": "VAR_NAME", "value": "value" }}]
"env": {}
}}
}}
"#,
json_opt_str(&self.id),
json_opt_str(&self.deploy_tag),
json_str(&self.fk_workspace),
json_str(&self.fk_project),
json_str(&svc.name),
json_str(&svc.deploy_type),
json_str(&svc.runtime),
json_str(&svc.root_path),
repo_section,
secret_line,
json_opt_str(&svc.build_command),
build_path,
pre_deploy,
json_opt_str(&svc.run_command),
json_str(&svc.fk_region),
json_str(&svc.fk_pod),
health_section,
svc.maintenance_mode,
svc.active,
env_json,
))
}
}
pub(crate) fn json_opt_str(opt: &Option<String>) -> String {
match opt {
Some(s) => serde_json::to_string(s).unwrap_or_else(|_| "null".to_string()),
None => "null".to_string(),
}
}
fn json_str(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{}\"", s))
}
#[derive(Debug)]
pub struct ValidationResult {
pub field: String,
pub ok: bool,
pub message: String,
}
pub fn validate_config(config: &PartiriConfig) -> Vec<ValidationResult> {
let svc = &config.service;
let mut results = Vec::new();
let mut check = |field: &str, ok: bool, msg: &str| {
results.push(ValidationResult {
field: field.to_string(),
ok,
message: msg.to_string(),
});
};
check("name", !svc.name.is_empty(), "Service name is required");
check(
"name_length",
svc.name.len() <= 16,
"Service name must be 16 characters or fewer",
);
check(
"deploy_type",
matches!(
svc.deploy_type.as_str(),
"webservice" | "static" | "private-service"
),
"Must be: webservice | static | private-service",
);
check(
"runtime",
matches!(
svc.runtime.as_str(),
"node" | "rust" | "python" | "go" | "ruby" | "elixir" | "php" | "jvm" | "dotnet" | "cpp" | "static" | "registry"
),
"Must be: node | rust | python | go | ruby | elixir | php | jvm | dotnet | cpp | static | registry",
);
check(
"root_path",
!svc.root_path.is_empty(),
"root_path is required",
);
check("fk_region", !svc.fk_region.is_empty(), "Region is required");
check("fk_pod", !svc.fk_pod.is_empty(), "Compute pod is required");
let has_repo = svc
.repository_url
.as_ref()
.map(|s| !s.is_empty())
.unwrap_or(false);
let has_reg = svc
.registry_url
.as_ref()
.map(|s| !s.is_empty())
.unwrap_or(false);
check(
"source",
has_repo ^ has_reg,
if has_repo && has_reg {
"Cannot have both repository_url and registry_url"
} else {
"Either repository_url or registry_url is required"
},
);
if svc.deploy_type == "static" && has_reg {
check(
"deploy_type/static",
false,
"deploy_type 'static' only supports repository source (not registry)",
);
}
if has_repo {
let build_ok = svc
.build_command
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
check(
"build_command",
build_ok,
"build_command is required for repository-sourced services",
);
if matches!(svc.deploy_type.as_str(), "webservice" | "private-service") {
let run_ok = svc
.run_command
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
check(
"run_command",
run_ok,
"run_command is required for webservice and private-service deploy types",
);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn valid_webservice() -> PartiriConfig {
PartiriConfig {
id: None,
deploy_tag: None,
fk_workspace: "ws-uuid".to_string(),
fk_project: "proj-uuid".to_string(),
service: ServiceConfig {
name: "my-service".to_string(),
deploy_type: "webservice".to_string(),
runtime: "node".to_string(),
root_path: ".".to_string(),
repository_url: Some("https://github.com/org/repo".to_string()),
repository_branch: Some("main".to_string()),
registry_url: None,
registry_repository_url: None,
fk_service_secret: None,
build_path: None,
build_command: Some("npm run build".to_string()),
pre_deploy_command: None,
run_command: Some("npm start".to_string()),
fk_region: "region-uuid".to_string(),
fk_pod: "pod-uuid".to_string(),
health_check_path: None,
maintenance_mode: false,
active: true,
env: vec![],
},
}
}
#[test]
fn valid_webservice_passes_all_checks() {
let config = valid_webservice();
let results = validate_config(&config);
assert!(
results.iter().all(|r| r.ok),
"unexpected failures: {:?}",
results
);
}
#[test]
fn valid_static_service_passes() {
let mut c = valid_webservice();
c.service.deploy_type = "static".to_string();
c.service.runtime = "static".to_string();
let results = validate_config(&c);
assert!(results.iter().all(|r| r.ok), "{:?}", results);
}
#[test]
fn valid_private_service_passes() {
let mut c = valid_webservice();
c.service.deploy_type = "private-service".to_string();
let results = validate_config(&c);
assert!(results.iter().all(|r| r.ok), "{:?}", results);
}
#[test]
fn all_valid_runtimes_pass() {
for runtime in &[
"node", "rust", "python", "go", "ruby", "elixir", "php", "jvm", "dotnet", "cpp",
"static", "registry",
] {
let mut c = valid_webservice();
c.service.runtime = runtime.to_string();
let r = validate_config(&c);
let check = r.iter().find(|r| r.field == "runtime").unwrap();
assert!(check.ok, "runtime '{}' should be valid", runtime);
}
}
#[test]
fn empty_name_fails() {
let mut c = valid_webservice();
c.service.name = "".to_string();
let r = validate_config(&c);
assert!(!r.iter().find(|r| r.field == "name").unwrap().ok);
}
#[test]
fn empty_fk_region_fails() {
let mut c = valid_webservice();
c.service.fk_region = "".to_string();
let r = validate_config(&c);
assert!(!r.iter().find(|r| r.field == "fk_region").unwrap().ok);
}
#[test]
fn empty_fk_pod_fails() {
let mut c = valid_webservice();
c.service.fk_pod = "".to_string();
let r = validate_config(&c);
assert!(!r.iter().find(|r| r.field == "fk_pod").unwrap().ok);
}
#[test]
fn invalid_deploy_type_fails() {
let mut c = valid_webservice();
c.service.deploy_type = "cronjob".to_string();
let r = validate_config(&c);
assert!(!r.iter().find(|r| r.field == "deploy_type").unwrap().ok);
}
#[test]
fn invalid_runtime_fails() {
let mut c = valid_webservice();
c.service.runtime = "cobol".to_string();
let r = validate_config(&c);
assert!(!r.iter().find(|r| r.field == "runtime").unwrap().ok);
}
#[test]
fn both_repo_and_registry_fails() {
let mut c = valid_webservice();
c.service.registry_url = Some("registry.example.com".to_string());
let r = validate_config(&c);
let check = r.iter().find(|r| r.field == "source").unwrap();
assert!(!check.ok);
assert!(check.message.contains("both"));
}
#[test]
fn neither_repo_nor_registry_fails() {
let mut c = valid_webservice();
c.service.repository_url = None;
let r = validate_config(&c);
assert!(!r.iter().find(|r| r.field == "source").unwrap().ok);
}
#[test]
fn static_with_registry_fails() {
let mut c = valid_webservice();
c.service.deploy_type = "static".to_string();
c.service.repository_url = None;
c.service.registry_url = Some("registry.example.com".to_string());
let r = validate_config(&c);
assert!(r.iter().any(|r| r.field == "deploy_type/static" && !r.ok));
}
#[test]
fn id_or_err_returns_id_when_set() {
let mut c = valid_webservice();
c.id = Some("svc-abc-123".to_string());
assert_eq!(c.id_or_err().unwrap(), "svc-abc-123");
}
#[test]
fn id_or_err_returns_err_when_none() {
let c = valid_webservice();
assert!(c.id_or_err().is_err());
}
#[test]
fn serde_json_roundtrip_preserves_fields() {
let config = valid_webservice();
let json = serde_json::to_string_pretty(&config).unwrap();
let loaded: PartiriConfig = json5::from_str(&json).unwrap();
assert_eq!(config.id, loaded.id);
assert_eq!(config.fk_workspace, loaded.fk_workspace);
assert_eq!(config.service.name, loaded.service.name);
assert_eq!(config.service.deploy_type, loaded.service.deploy_type);
assert_eq!(config.service.runtime, loaded.service.runtime);
assert_eq!(config.service.fk_region, loaded.service.fk_region);
assert_eq!(config.service.fk_pod, loaded.service.fk_pod);
assert_eq!(config.service.repository_url, loaded.service.repository_url);
}
#[test]
fn env_vars_survive_roundtrip() {
let mut config = valid_webservice();
config.service.env = vec![
EnvVar {
key: "DATABASE_URL".to_string(),
value: "postgres://localhost/db".to_string(),
},
EnvVar {
key: "PORT".to_string(),
value: "3000".to_string(),
},
];
let json = serde_json::to_string_pretty(&config).unwrap();
let loaded: PartiriConfig = json5::from_str(&json).unwrap();
assert_eq!(loaded.service.env.len(), 2);
assert_eq!(loaded.service.env[0].key, "DATABASE_URL");
assert_eq!(loaded.service.env[1].value, "3000");
}
#[test]
fn optional_fields_omitted_when_none() {
let mut config = valid_webservice();
config.service.build_command = None;
let json = serde_json::to_string_pretty(&config).unwrap();
assert!(!json.contains("registry_url"));
assert!(!json.contains("build_command"));
assert!(!json.contains("fk_service_secret"));
}
#[test]
fn to_jsonc_string_roundtrip() {
let config = valid_webservice();
let jsonc = config.to_jsonc_string().unwrap();
let loaded: PartiriConfig = json5::from_str(&jsonc).unwrap();
assert_eq!(config.service.name, loaded.service.name);
assert_eq!(config.service.deploy_type, loaded.service.deploy_type);
assert_eq!(config.service.fk_region, loaded.service.fk_region);
assert_eq!(config.service.repository_url, loaded.service.repository_url);
}
proptest! {
#[test]
fn validate_config_never_panics(
name in ".*",
deploy_type in ".*",
runtime in ".*",
root_path in ".*",
) {
let mut c = valid_webservice();
c.service.name = name;
c.service.deploy_type = deploy_type;
c.service.runtime = runtime;
c.service.root_path = root_path;
let _ = validate_config(&c);
}
#[test]
fn known_valid_deploy_types_always_pass_check(
dt in proptest::sample::select(vec!["webservice", "static", "private-service"])
) {
let mut c = valid_webservice();
c.service.deploy_type = dt.to_string();
let r = validate_config(&c);
assert!(r.iter().find(|r| r.field == "deploy_type").unwrap().ok);
}
#[test]
fn known_valid_runtimes_always_pass_check(
rt in proptest::sample::select(vec![
"node", "rust", "python", "go", "ruby", "elixir", "php", "jvm", "dotnet", "cpp", "static", "registry",
])
) {
let mut c = valid_webservice();
c.service.runtime = rt.to_string();
let r = validate_config(&c);
assert!(r.iter().find(|r| r.field == "runtime").unwrap().ok);
}
}
#[test]
fn to_jsonc_string_preserves_comments_and_roundtrips() {
let config = valid_webservice();
let data = config.to_jsonc_string().unwrap();
assert!(data.contains("//"), "JSONC output should contain comments");
let loaded: PartiriConfig = json5::from_str(&data).unwrap();
assert_eq!(config.service.name, loaded.service.name);
assert_eq!(config.id, loaded.id);
}
#[test]
fn to_jsonc_string_with_deploy_tag_set_roundtrips() {
let mut config = valid_webservice();
config.deploy_tag = Some("ab12c".to_string());
let jsonc = config.to_jsonc_string().unwrap();
assert!(jsonc.contains("ab12c"));
assert!(jsonc.contains("deploy_tag"));
let loaded: PartiriConfig = json5::from_str(&jsonc).unwrap();
assert_eq!(loaded.deploy_tag, Some("ab12c".to_string()));
}
#[test]
fn deploy_tag_none_deserializes_from_missing_field() {
let json = r#"{"id": null, "fk_workspace": "ws", "fk_project": "proj",
"service": {"name": "s", "deploy_type": "webservice", "runtime": "node",
"root_path": ".", "repository_url": "https://github.com/x/y",
"fk_region": "r", "fk_pod": "p", "maintenance_mode": false, "active": true}}"#;
let config: PartiriConfig = json5::from_str(json).unwrap();
assert!(config.deploy_tag.is_none());
}
#[test]
fn to_jsonc_string_with_id_set_roundtrips() {
let mut config = valid_webservice();
config.id = Some("svc-new-id-123".to_string());
let jsonc = config.to_jsonc_string().unwrap();
assert!(jsonc.contains("svc-new-id-123"));
assert!(jsonc.contains("//"));
let loaded: PartiriConfig = json5::from_str(&jsonc).unwrap();
assert_eq!(loaded.id, Some("svc-new-id-123".to_string()));
}
}