systemprompt_cli/commands/admin/config/
services_io.rs1use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result};
18use serde::{Deserialize, Serialize};
19use systemprompt_loader::ServicesBootstrap;
20use systemprompt_models::services::{GatewayState, ProviderRegistry, ServicesConfig};
21
22use super::config_section::{ConfigSection, GATEWAY_INCLUDE_RELATIVE, PROVIDERS_INCLUDE_RELATIVE};
23
24#[derive(Debug, Default, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct ProvidersFile {
27 #[serde(default)]
28 pub providers: ProviderRegistry,
29}
30
31#[derive(Debug, Default, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct GatewayFile {
34 #[serde(default)]
35 pub gateway: Option<GatewayState>,
36}
37
38#[derive(Debug)]
39pub struct ServicesFile<T> {
40 pub path: PathBuf,
41 pub existed: bool,
42 pub content: T,
43}
44
45pub(super) fn load_providers_file() -> Result<ServicesFile<ProvidersFile>> {
46 load_file(ConfigSection::Providers.file_path()?)
47}
48
49pub(super) fn load_gateway_file() -> Result<ServicesFile<GatewayFile>> {
50 load_file(ConfigSection::Gateway.file_path()?)
51}
52
53fn load_file<T: Default + for<'de> Deserialize<'de>>(path: PathBuf) -> Result<ServicesFile<T>> {
54 if !path.exists() {
55 return Ok(ServicesFile {
56 path,
57 existed: false,
58 content: T::default(),
59 });
60 }
61 let raw = std::fs::read_to_string(&path)
62 .with_context(|| format!("Failed to read {}", path.display()))?;
63 let content: T = serde_yaml::from_str(&raw)
64 .with_context(|| format!("Failed to parse {}", path.display()))?;
65 Ok(ServicesFile {
66 path,
67 existed: true,
68 content,
69 })
70}
71
72pub(super) fn save_file<T: Serialize>(file: &ServicesFile<T>, relative: &str) -> Result<()> {
73 if let Some(parent) = file.path.parent() {
74 std::fs::create_dir_all(parent)
75 .with_context(|| format!("Failed to create {}", parent.display()))?;
76 }
77 let body = serde_yaml::to_string(&file.content).context("Failed to serialize services file")?;
78 std::fs::write(&file.path, body)
79 .with_context(|| format!("Failed to write {}", file.path.display()))?;
80 if !file.existed {
81 ensure_included(relative)?;
82 }
83 Ok(())
84}
85
86pub(super) fn booted_services() -> Result<&'static ServicesConfig> {
87 ServicesBootstrap::get().context("services config is not loaded")
88}
89
90pub(super) fn merged_registry_after_edit(
94 before: &ProviderRegistry,
95 after: &ProviderRegistry,
96) -> Result<ProviderRegistry> {
97 let booted = booted_services()?;
98 let mut merged = ProviderRegistry {
99 providers: booted
100 .providers
101 .providers
102 .iter()
103 .filter(|p| before.find_provider(p.name.as_str()).is_none())
104 .cloned()
105 .collect(),
106 };
107 for provider in &after.providers {
108 if merged.find_provider(provider.name.as_str()).is_some() {
109 anyhow::bail!(
110 "provider '{}' is already declared by another services include",
111 provider.name.as_str()
112 );
113 }
114 merged.providers.push(provider.clone());
115 }
116 merged
117 .validate()
118 .context("provider registry is invalid after edit; refusing to write")?;
119 Ok(merged)
120}
121
122fn ensure_included(relative: &str) -> Result<()> {
123 let root = ConfigSection::Services.file_path()?;
124 append_include(&root, relative)
125}
126
127pub fn append_include(root: &Path, relative: &str) -> Result<()> {
131 let existing = std::fs::read_to_string(root).unwrap_or_default();
132 let already = existing.lines().any(|line| {
133 let item = line.trim_start().strip_prefix("- ").map(str::trim);
134 item.is_some_and(|value| value.trim_matches(['"', '\'']) == relative)
135 });
136 if already {
137 return Ok(());
138 }
139 let entry = format!(" - {relative}\n");
140 let updated = match existing.find("\nincludes:") {
141 Some(idx) => {
142 let insert_at = idx + "\nincludes:".len();
143 let line_end = existing[insert_at..]
144 .find('\n')
145 .map_or(existing.len(), |n| insert_at + n + 1);
146 format!("{}{entry}{}", &existing[..line_end], &existing[line_end..])
147 },
148 None if existing.starts_with("includes:") => {
149 let line_end = existing.find('\n').map_or(existing.len(), |n| n + 1);
150 format!("{}{entry}{}", &existing[..line_end], &existing[line_end..])
151 },
152 None => {
153 let sep = if existing.is_empty() || existing.ends_with('\n') {
154 ""
155 } else {
156 "\n"
157 };
158 format!("{existing}{sep}includes:\n{entry}")
159 },
160 };
161 std::fs::write(root, updated).with_context(|| format!("Failed to write {}", root.display()))
162}
163
164pub(super) const fn providers_relative() -> &'static str {
165 PROVIDERS_INCLUDE_RELATIVE
166}
167
168pub(super) const fn gateway_relative() -> &'static str {
169 GATEWAY_INCLUDE_RELATIVE
170}