use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use systemprompt_loader::ServicesBootstrap;
use systemprompt_models::services::{GatewayState, ProviderRegistry, ServicesConfig};
use super::config_section::{ConfigSection, GATEWAY_INCLUDE_RELATIVE, PROVIDERS_INCLUDE_RELATIVE};
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProvidersFile {
#[serde(default)]
pub providers: ProviderRegistry,
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GatewayFile {
#[serde(default)]
pub gateway: Option<GatewayState>,
}
#[derive(Debug)]
pub struct ServicesFile<T> {
pub path: PathBuf,
pub existed: bool,
pub content: T,
}
pub(super) fn load_providers_file() -> Result<ServicesFile<ProvidersFile>> {
load_file(ConfigSection::Providers.file_path()?)
}
pub(super) fn load_gateway_file() -> Result<ServicesFile<GatewayFile>> {
load_file(ConfigSection::Gateway.file_path()?)
}
fn load_file<T: Default + for<'de> Deserialize<'de>>(path: PathBuf) -> Result<ServicesFile<T>> {
if !path.exists() {
return Ok(ServicesFile {
path,
existed: false,
content: T::default(),
});
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let content: T = serde_yaml::from_str(&raw)
.with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(ServicesFile {
path,
existed: true,
content,
})
}
pub(super) fn save_file<T: Serialize>(file: &ServicesFile<T>, relative: &str) -> Result<()> {
if let Some(parent) = file.path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let body = serde_yaml::to_string(&file.content).context("Failed to serialize services file")?;
std::fs::write(&file.path, body)
.with_context(|| format!("Failed to write {}", file.path.display()))?;
if !file.existed {
ensure_included(relative)?;
}
Ok(())
}
pub(super) fn booted_services() -> Result<&'static ServicesConfig> {
ServicesBootstrap::get().context("services config is not loaded")
}
pub(super) fn merged_registry_after_edit(
before: &ProviderRegistry,
after: &ProviderRegistry,
) -> Result<ProviderRegistry> {
let booted = booted_services()?;
let mut merged = ProviderRegistry {
providers: booted
.providers
.providers
.iter()
.filter(|p| before.find_provider(p.name.as_str()).is_none())
.cloned()
.collect(),
};
for provider in &after.providers {
if merged.find_provider(provider.name.as_str()).is_some() {
anyhow::bail!(
"provider '{}' is already declared by another services include",
provider.name.as_str()
);
}
merged.providers.push(provider.clone());
}
merged
.validate()
.context("provider registry is invalid after edit; refusing to write")?;
Ok(merged)
}
fn ensure_included(relative: &str) -> Result<()> {
let root = ConfigSection::Services.file_path()?;
append_include(&root, relative)
}
pub fn append_include(root: &Path, relative: &str) -> Result<()> {
let existing = std::fs::read_to_string(root).unwrap_or_default();
let already = existing.lines().any(|line| {
let item = line.trim_start().strip_prefix("- ").map(str::trim);
item.is_some_and(|value| value.trim_matches(['"', '\'']) == relative)
});
if already {
return Ok(());
}
let entry = format!(" - {relative}\n");
let updated = match existing.find("\nincludes:") {
Some(idx) => {
let insert_at = idx + "\nincludes:".len();
let line_end = existing[insert_at..]
.find('\n')
.map_or(existing.len(), |n| insert_at + n + 1);
format!("{}{entry}{}", &existing[..line_end], &existing[line_end..])
},
None if existing.starts_with("includes:") => {
let line_end = existing.find('\n').map_or(existing.len(), |n| n + 1);
format!("{}{entry}{}", &existing[..line_end], &existing[line_end..])
},
None => {
let sep = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
format!("{existing}{sep}includes:\n{entry}")
},
};
std::fs::write(root, updated).with_context(|| format!("Failed to write {}", root.display()))
}
pub(super) const fn providers_relative() -> &'static str {
PROVIDERS_INCLUDE_RELATIVE
}
pub(super) const fn gateway_relative() -> &'static str {
GATEWAY_INCLUDE_RELATIVE
}