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_manifest::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(
91 before: &ProviderRegistry,
92 after: &ProviderRegistry,
93) -> Result<ProviderRegistry> {
94 let booted = booted_services()?;
95 let mut merged = ProviderRegistry {
96 providers: booted
97 .providers
98 .providers
99 .iter()
100 .filter(|p| before.find_provider(p.name.as_str()).is_none())
101 .cloned()
102 .collect(),
103 };
104 for provider in &after.providers {
105 if merged.find_provider(provider.name.as_str()).is_some() {
106 anyhow::bail!(
107 "provider '{}' is already declared by another services include",
108 provider.name.as_str()
109 );
110 }
111 merged.providers.push(provider.clone());
112 }
113 merged
114 .validate()
115 .context("provider registry is invalid after edit; refusing to write")?;
116 Ok(merged)
117}
118
119fn ensure_included(relative: &str) -> Result<()> {
120 let root = ConfigSection::Services.file_path()?;
121 append_include(&root, relative)
122}
123
124pub fn append_include(root: &Path, relative: &str) -> Result<()> {
126 let existing = match std::fs::read_to_string(root) {
127 Ok(existing) => existing,
128 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
129 Err(e) => {
130 return Err(e).with_context(|| format!("Failed to read {}", root.display()));
131 },
132 };
133 let already = existing.lines().any(|line| {
134 let item = line.trim_start().strip_prefix("- ").map(str::trim);
135 item.is_some_and(|value| value.trim_matches(['"', '\'']) == relative)
136 });
137 if already {
138 return Ok(());
139 }
140 let entry = format!(" - {relative}\n");
141 let splice = |line_end: Option<usize>| {
142 line_end.map_or_else(
143 || format!("{existing}\n{entry}"),
144 |end| format!("{}{entry}{}", &existing[..end], &existing[end..]),
145 )
146 };
147 let updated = match existing.find("\nincludes:") {
148 Some(idx) => {
149 let insert_at = idx + "\nincludes:".len();
150 splice(existing[insert_at..].find('\n').map(|n| insert_at + n + 1))
151 },
152 None if existing.starts_with("includes:") => splice(existing.find('\n').map(|n| n + 1)),
153 None => {
154 let sep = if existing.is_empty() || existing.ends_with('\n') {
155 ""
156 } else {
157 "\n"
158 };
159 format!("{existing}{sep}includes:\n{entry}")
160 },
161 };
162 std::fs::write(root, updated).with_context(|| format!("Failed to write {}", root.display()))
163}
164
165pub(super) const fn providers_relative() -> &'static str {
166 PROVIDERS_INCLUDE_RELATIVE
167}
168
169pub(super) const fn gateway_relative() -> &'static str {
170 GATEWAY_INCLUDE_RELATIVE
171}