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