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