Skip to main content

systemprompt_models/profile/
mod.rs

1//! Profile configuration models — the deserialized shape of a
2//! `.systemprompt/profiles/<name>/profile.yaml` document.
3//!
4//! Covers server, database, paths, secrets, security, rate limits,
5//! gateway, governance, and runtime sections, plus validation rules and
6//! environment-variable interpolation.
7
8mod cloud;
9mod database;
10mod error;
11mod from_env;
12mod gateway;
13mod governance;
14mod info;
15mod paths;
16mod rate_limits;
17mod runtime;
18mod secrets;
19mod security;
20mod server;
21mod site;
22mod style;
23mod validation;
24
25pub use cloud::{CloudConfig, CloudValidationMode};
26pub use database::DatabaseConfig;
27pub use error::{ProfileError, ProfileResult};
28pub use gateway::{
29    GatewayCatalog, GatewayConfig, GatewayModel, GatewayProfileError, GatewayProvider,
30    GatewayResult, GatewayRoute, slugify_pattern, synthesize_route_id,
31};
32pub use governance::{
33    AuthzConfig, AuthzHookConfig, AuthzMode, GovernanceConfig, UNRESTRICTED_ACKNOWLEDGEMENT,
34};
35pub use info::ProfileInfo;
36pub use paths::{PathsConfig, expand_home, resolve_path, resolve_with_home};
37pub use rate_limits::{
38    RateLimitsConfig, TierMultipliers, default_a2a_multiplier, default_admin_multiplier,
39    default_agent_registry, default_agents, default_anon_multiplier, default_artifacts,
40    default_burst, default_content, default_contexts, default_mcp, default_mcp_multiplier,
41    default_mcp_registry, default_oauth_auth, default_oauth_public, default_service_multiplier,
42    default_stream, default_tasks, default_user_multiplier,
43};
44pub use runtime::{Environment, LogLevel, OutputFormat, RuntimeConfig};
45pub use secrets::{SecretsConfig, SecretsSource, SecretsValidationMode};
46pub use security::SecurityConfig;
47pub use server::{ContentNegotiationConfig, SecurityHeadersConfig, ServerConfig};
48pub use site::SiteConfig;
49pub use style::ProfileStyle;
50
51use regex::Regex;
52use serde::{Deserialize, Serialize};
53use std::path::Path;
54use std::sync::LazyLock;
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct ExtensionsConfig {
58    #[serde(default)]
59    pub disabled: Vec<String>,
60}
61
62impl ExtensionsConfig {
63    pub fn is_disabled(&self, extension_id: &str) -> bool {
64        self.disabled.iter().any(|id| id == extension_id)
65    }
66}
67
68#[allow(clippy::expect_used)]
69static ENV_VAR_REGEX: LazyLock<Regex> = LazyLock::new(|| {
70    Regex::new(r"\$\{(\w+)\}")
71        .expect("ENV_VAR_REGEX is a valid regex - this is a compile-time constant")
72});
73
74fn env_var_regex() -> &'static Regex {
75    &ENV_VAR_REGEX
76}
77
78fn substitute_env_vars(content: &str) -> String {
79    env_var_regex()
80        .replace_all(content, |caps: &regex::Captures| {
81            let var_name = &caps[1];
82            std::env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
83        })
84        .to_string()
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
88#[serde(rename_all = "lowercase")]
89pub enum ProfileType {
90    #[default]
91    Local,
92    Cloud,
93}
94
95impl ProfileType {
96    pub const fn is_cloud(&self) -> bool {
97        matches!(self, Self::Cloud)
98    }
99
100    pub const fn is_local(&self) -> bool {
101        matches!(self, Self::Local)
102    }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct Profile {
107    pub name: String,
108
109    pub display_name: String,
110
111    #[serde(default)]
112    pub target: ProfileType,
113
114    pub site: SiteConfig,
115
116    pub database: DatabaseConfig,
117
118    pub server: ServerConfig,
119
120    pub paths: PathsConfig,
121
122    pub security: SecurityConfig,
123
124    pub rate_limits: RateLimitsConfig,
125
126    #[serde(default)]
127    pub runtime: RuntimeConfig,
128
129    #[serde(default)]
130    pub cloud: Option<CloudConfig>,
131
132    #[serde(default)]
133    pub secrets: Option<SecretsConfig>,
134
135    #[serde(default)]
136    pub extensions: ExtensionsConfig,
137
138    #[serde(default)]
139    pub gateway: Option<GatewayConfig>,
140
141    #[serde(default)]
142    pub governance: Option<GovernanceConfig>,
143}
144
145impl Profile {
146    #[must_use]
147    pub fn is_local_trial(&self) -> bool {
148        self.cloud.as_ref().is_none_or(CloudConfig::is_local_trial)
149    }
150
151    pub fn from_yaml(content: &str, profile_path: &Path) -> ProfileResult<Self> {
152        let content = substitute_env_vars(content);
153
154        let mut profile: Self =
155            serde_yaml::from_str(&content).map_err(|source| ProfileError::ParseYaml {
156                path: profile_path.to_path_buf(),
157                source,
158            })?;
159
160        let profile_dir =
161            profile_path
162                .parent()
163                .ok_or_else(|| ProfileError::InvalidProfilePath {
164                    path: profile_path.to_path_buf(),
165                })?;
166
167        profile.paths.resolve_relative_to(profile_dir);
168
169        Ok(profile)
170    }
171
172    pub fn to_yaml(&self) -> ProfileResult<String> {
173        serde_yaml::to_string(self).map_err(ProfileError::SerializeYaml)
174    }
175
176    pub fn profile_style(&self) -> ProfileStyle {
177        match self.name.to_lowercase().as_str() {
178            "dev" | "development" | "local" => ProfileStyle::Development,
179            "prod" | "production" => ProfileStyle::Production,
180            "staging" | "stage" => ProfileStyle::Staging,
181            "test" | "testing" => ProfileStyle::Test,
182            _ => ProfileStyle::Custom,
183        }
184    }
185
186    pub fn mask_secret(value: &str, visible_chars: usize) -> String {
187        if value.is_empty() {
188            return "(not set)".to_string();
189        }
190        if value.len() <= visible_chars {
191            return "***".to_string();
192        }
193        format!("{}...", &value[..visible_chars])
194    }
195
196    pub fn mask_database_url(url: &str) -> String {
197        if let Some(at_pos) = url.find('@') {
198            if let Some(colon_pos) = url[..at_pos].rfind(':') {
199                let prefix = &url[..=colon_pos];
200                let suffix = &url[at_pos..];
201                return format!("{}***{}", prefix, suffix);
202            }
203        }
204        url.to_string()
205    }
206
207    pub fn is_masked_database_url(url: &str) -> bool {
208        url.contains(":***@") || url.contains(":********@")
209    }
210}