Skip to main content

systemprompt_cli/commands/admin/config/
config_section.rs

1//! Config-section model and YAML helpers shared across the `admin config` tree.
2//!
3//! [`ConfigSection`] enumerates the profile and per-service config locations
4//! — including the provider catalog and gateway routes, which are services
5//! files (`ai/providers.yaml`, `ai/gateway.yaml`) rather than profile sections
6//! — and resolves them to filesystem paths, while [`read_yaml_file`] and
7//! [`write_yaml_file`] back the editing commands. The remaining types are the
8//! serializable outputs rendered by the list, validate, and import/export
9//! flows.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use systemprompt_config::ProfileBootstrap;
20
21use super::rate_limit_types::ResetChange;
22
23pub const PROVIDERS_FILE_RELATIVE: &str = "ai/providers.yaml";
24pub const GATEWAY_FILE_RELATIVE: &str = "ai/gateway.yaml";
25pub const PROVIDERS_INCLUDE_RELATIVE: &str = "../ai/providers.yaml";
26pub const GATEWAY_INCLUDE_RELATIVE: &str = "../ai/gateway.yaml";
27
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
29pub struct ValidateOutput {
30    pub valid: bool,
31    pub errors: Vec<String>,
32    pub warnings: Vec<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
36pub struct ExportOutput {
37    pub format: String,
38    pub path: String,
39    pub message: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
43pub struct ImportOutput {
44    pub path: String,
45    pub changes: Vec<ResetChange>,
46    pub message: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
50pub struct DiffOutput {
51    pub source: String,
52    pub differences: Vec<DiffEntry>,
53    pub identical: bool,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
57pub struct DiffEntry {
58    pub field: String,
59    pub current: String,
60    pub other: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
64pub struct ConfigFileInfo {
65    pub path: String,
66    pub section: String,
67    pub exists: bool,
68    pub valid: bool,
69    pub error: Option<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
73pub struct ConfigListOutput {
74    pub total: usize,
75    pub valid: usize,
76    pub invalid: usize,
77    pub files: Vec<ConfigFileInfo>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
81pub struct ConfigValidateOutput {
82    pub files: Vec<ConfigFileInfo>,
83    pub all_valid: bool,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ConfigSection {
88    Ai,
89    Providers,
90    Gateway,
91    Content,
92    Web,
93    Scheduler,
94    Agents,
95    Mcp,
96    Skills,
97    Profile,
98    Services,
99}
100
101impl ConfigSection {
102    pub const fn all() -> &'static [Self] {
103        &[
104            Self::Profile,
105            Self::Services,
106            Self::Ai,
107            Self::Providers,
108            Self::Gateway,
109            Self::Content,
110            Self::Web,
111            Self::Scheduler,
112            Self::Agents,
113            Self::Mcp,
114            Self::Skills,
115        ]
116    }
117
118    pub fn file_path(self) -> Result<PathBuf> {
119        let profile = ProfileBootstrap::get()?;
120        match self {
121            Self::Ai => Ok(PathBuf::from(&profile.paths.services).join("ai/config.yaml")),
122            Self::Providers => {
123                Ok(PathBuf::from(&profile.paths.services).join(PROVIDERS_FILE_RELATIVE))
124            },
125            Self::Gateway => Ok(PathBuf::from(&profile.paths.services).join(GATEWAY_FILE_RELATIVE)),
126            Self::Content => Ok(PathBuf::from(&profile.paths.services).join("content/config.yaml")),
127            Self::Web => Ok(PathBuf::from(&profile.paths.services).join("web/config.yaml")),
128            Self::Scheduler => {
129                Ok(PathBuf::from(&profile.paths.services).join("scheduler/config.yaml"))
130            },
131            Self::Agents => Ok(PathBuf::from(&profile.paths.services).join("agents/config.yaml")),
132            Self::Mcp => Ok(PathBuf::from(&profile.paths.services).join("mcp/config.yaml")),
133            Self::Skills => Ok(PathBuf::from(&profile.paths.services).join("skills/config.yaml")),
134            Self::Profile => Ok(PathBuf::from(ProfileBootstrap::get_path()?)),
135            Self::Services => Ok(PathBuf::from(&profile.paths.services).join("config/config.yaml")),
136        }
137    }
138
139    pub fn all_files(self) -> Result<Vec<PathBuf>> {
140        let profile = ProfileBootstrap::get()?;
141        let services_path = PathBuf::from(&profile.paths.services);
142        let profile_path = PathBuf::from(ProfileBootstrap::get_path()?);
143        self.files_under(&services_path, &profile_path)
144    }
145
146    pub fn files_under(self, services_path: &Path, profile_path: &Path) -> Result<Vec<PathBuf>> {
147        match self {
148            Self::Profile => Ok(vec![profile_path.to_path_buf()]),
149            Self::Services => Ok(vec![services_path.join("config/config.yaml")]),
150            Self::Ai => Ok(Self::collect_yaml_files(&services_path.join("ai"))?
151                .into_iter()
152                .filter(|p| {
153                    p != &services_path.join(PROVIDERS_FILE_RELATIVE)
154                        && p != &services_path.join(GATEWAY_FILE_RELATIVE)
155                })
156                .collect()),
157            Self::Providers => Ok(vec![services_path.join(PROVIDERS_FILE_RELATIVE)]),
158            Self::Gateway => Ok(vec![services_path.join(GATEWAY_FILE_RELATIVE)]),
159            Self::Content => Self::collect_yaml_files(&services_path.join("content")),
160            Self::Web => Self::collect_yaml_files(&services_path.join("web")),
161            Self::Scheduler => Self::collect_yaml_files(&services_path.join("scheduler")),
162            Self::Agents => Self::collect_yaml_files(&services_path.join("agents")),
163            Self::Mcp => Self::collect_yaml_files(&services_path.join("mcp")),
164            Self::Skills => Self::collect_yaml_files(&services_path.join("skills")),
165        }
166    }
167
168    fn collect_yaml_files(dir: &Path) -> Result<Vec<PathBuf>> {
169        let mut files = Vec::new();
170        if dir.exists() {
171            Self::collect_yaml_recursive(dir, &mut files)?;
172        }
173        Ok(files)
174    }
175
176    fn collect_yaml_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
177        if !dir.is_dir() {
178            return Ok(());
179        }
180
181        let entries = std::fs::read_dir(dir)
182            .with_context(|| format!("Failed to read config directory {}", dir.display()))?;
183        for entry in entries {
184            let entry = entry?;
185            let path = entry.path();
186
187            if path.is_dir() {
188                Self::collect_yaml_recursive(&path, files)?;
189            } else if let Some(ext) = path.extension()
190                && (ext == "yaml" || ext == "yml")
191            {
192                files.push(path);
193            }
194        }
195        Ok(())
196    }
197}
198
199impl std::fmt::Display for ConfigSection {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        match self {
202            Self::Ai => write!(f, "ai"),
203            Self::Providers => write!(f, "providers"),
204            Self::Gateway => write!(f, "gateway"),
205            Self::Content => write!(f, "content"),
206            Self::Web => write!(f, "web"),
207            Self::Scheduler => write!(f, "scheduler"),
208            Self::Agents => write!(f, "agents"),
209            Self::Mcp => write!(f, "mcp"),
210            Self::Skills => write!(f, "skills"),
211            Self::Profile => write!(f, "profile"),
212            Self::Services => write!(f, "services"),
213        }
214    }
215}
216
217impl std::str::FromStr for ConfigSection {
218    type Err = anyhow::Error;
219
220    fn from_str(s: &str) -> Result<Self> {
221        match s.to_lowercase().as_str() {
222            "ai" => Ok(Self::Ai),
223            "providers" => Ok(Self::Providers),
224            "gateway" => Ok(Self::Gateway),
225            "content" => Ok(Self::Content),
226            "web" => Ok(Self::Web),
227            "scheduler" => Ok(Self::Scheduler),
228            "agents" => Ok(Self::Agents),
229            "mcp" => Ok(Self::Mcp),
230            "skills" => Ok(Self::Skills),
231            "profile" => Ok(Self::Profile),
232            "services" => Ok(Self::Services),
233            _ => Err(anyhow::anyhow!("Unknown config section: {}", s)),
234        }
235    }
236}
237
238pub fn read_yaml_file(path: &Path) -> Result<serde_yaml::Value> {
239    // JSON: an untyped document preserves unknown keys in operator-authored
240    // config so a single field can be mutated and written back losslessly.
241    let content = std::fs::read_to_string(path)
242        .with_context(|| format!("Failed to read file: {}", path.display()))?;
243    serde_yaml::from_str(&content)
244        .with_context(|| format!("Failed to parse YAML from: {}", path.display()))
245}
246
247pub fn write_yaml_file(path: &Path, content: &serde_yaml::Value) -> Result<()> {
248    // JSON: writes back the untyped document read by `read_yaml_file`, keeping
249    // operator-authored keys this tooling does not model.
250    let yaml_str = serde_yaml::to_string(content).with_context(|| "Failed to serialize YAML")?;
251    std::fs::write(path, yaml_str)
252        .with_context(|| format!("Failed to write file: {}", path.display()))
253}