systemprompt_cli/shared/
profile.rs1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use rand::distr::Alphanumeric;
5use rand::{Rng, rng};
6use systemprompt_cloud::{ProfilePath, ProjectContext};
7use systemprompt_loader::ProfileLoader;
8use systemprompt_models::Profile;
9
10#[derive(Debug, thiserror::Error)]
11pub enum ProfileResolutionError {
12 #[error(
13 "No profiles found.\n\nCreate a profile with: systemprompt cloud profile create <name>"
14 )]
15 NoProfilesFound,
16
17 #[error(
18 "Profile '{0}' not found.\n\nRun 'systemprompt cloud profile list' to see available \
19 profiles."
20 )]
21 ProfileNotFound(String),
22
23 #[error("Profile discovery failed: {0}")]
24 DiscoveryFailed(#[from] anyhow::Error),
25
26 #[error(
27 "Multiple profiles found: {profiles:?}\n\nUse --profile <name> or 'systemprompt admin \
28 session switch <profile>'"
29 )]
30 MultipleProfilesFound { profiles: Vec<String> },
31}
32
33pub fn resolve_profile_path(
34 cli_override: Option<&str>,
35 from_session: Option<PathBuf>,
36) -> Result<PathBuf, ProfileResolutionError> {
37 if let Some(profile_input) = cli_override {
38 return resolve_profile_input(profile_input);
39 }
40
41 if let Ok(path_str) = std::env::var("SYSTEMPROMPT_PROFILE") {
42 return resolve_profile_input(&path_str);
43 }
44
45 if let Some(path) = from_session.filter(|p| p.exists()) {
46 return Ok(path);
47 }
48
49 let mut profiles = discover_profiles()?;
50 match profiles.len() {
51 0 => Err(ProfileResolutionError::NoProfilesFound),
52 1 => Ok(profiles.swap_remove(0).path),
53 _ => Err(ProfileResolutionError::MultipleProfilesFound {
54 profiles: profiles.iter().map(|p| p.name.clone()).collect(),
55 }),
56 }
57}
58
59pub fn is_path_input(input: &str) -> bool {
60 let path = Path::new(input);
61 let has_yaml_extension = path
62 .extension()
63 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"));
64
65 input.contains(std::path::MAIN_SEPARATOR)
66 || input.contains('/')
67 || has_yaml_extension
68 || input.starts_with('.')
69 || input.starts_with('~')
70}
71
72fn resolve_profile_input(input: &str) -> Result<PathBuf, ProfileResolutionError> {
73 if is_path_input(input) {
74 return resolve_profile_from_path(input);
75 }
76 resolve_profile_by_name(input)?
77 .ok_or_else(|| ProfileResolutionError::ProfileNotFound(input.to_string()))
78}
79
80pub fn resolve_profile_from_path(path_str: &str) -> Result<PathBuf, ProfileResolutionError> {
81 let path = expand_path(path_str);
82
83 if path.exists() {
84 return Ok(path);
85 }
86
87 let profile_yaml = path.join("profile.yaml");
88 if profile_yaml.exists() {
89 return Ok(profile_yaml);
90 }
91
92 Err(ProfileResolutionError::ProfileNotFound(
93 path_str.to_string(),
94 ))
95}
96
97fn expand_path(path_str: &str) -> PathBuf {
98 if path_str.starts_with('~') {
99 if let Some(home) = dirs::home_dir() {
100 return home.join(
101 path_str
102 .strip_prefix("~/")
103 .unwrap_or_else(|| &path_str[1..]),
104 );
105 }
106 }
107 PathBuf::from(path_str)
108}
109
110pub fn resolve_profile_with_data(
111 profile_input: &str,
112) -> Result<(PathBuf, Profile), ProfileResolutionError> {
113 let path = resolve_profile_input(profile_input)?;
114 let profile =
115 ProfileLoader::load_from_path(&path).map_err(ProfileResolutionError::DiscoveryFailed)?;
116 Ok((path, profile))
117}
118
119fn resolve_profile_by_name(name: &str) -> Result<Option<PathBuf>, ProfileResolutionError> {
120 let ctx = ProjectContext::discover();
121 let profiles_dir = ctx.profiles_dir();
122 let target_dir = profiles_dir.join(name);
123 let config_path = ProfilePath::Config.resolve(&target_dir);
124
125 if config_path.exists() {
126 return Ok(Some(config_path));
127 }
128
129 let profiles = discover_profiles()?;
130 if let Some(found) = profiles.into_iter().find(|p| p.name == name) {
131 return Ok(Some(found.path));
132 }
133
134 {
135 let paths = crate::paths::ResolvedPaths::discover().sessions_dir();
136 if let Ok(store) = systemprompt_cloud::SessionStore::load_or_create(&paths) {
137 if let Some(session) = store.find_by_profile_name(name) {
138 if let Some(ref profile_path) = session.profile_path {
139 if profile_path.exists() {
140 return Ok(Some(profile_path.clone()));
141 }
142 }
143 }
144 }
145 }
146
147 Ok(None)
148}
149
150#[derive(Debug)]
151pub struct DiscoveredProfile {
152 pub name: String,
153 pub path: PathBuf,
154 pub profile: Profile,
155}
156
157pub fn discover_profiles() -> Result<Vec<DiscoveredProfile>> {
158 let ctx = ProjectContext::discover();
159 let profiles_dir = ctx.profiles_dir();
160
161 if !profiles_dir.exists() {
162 return Ok(Vec::new());
163 }
164
165 let entries = std::fs::read_dir(&profiles_dir).with_context(|| {
166 format!(
167 "Failed to read profiles directory: {}",
168 profiles_dir.display()
169 )
170 })?;
171
172 let profiles = entries
173 .filter_map(std::result::Result::ok)
174 .filter(|e| e.path().is_dir())
175 .filter_map(|e| build_discovered_profile(&e))
176 .collect();
177
178 Ok(profiles)
179}
180
181fn build_discovered_profile(entry: &std::fs::DirEntry) -> Option<DiscoveredProfile> {
182 let profile_yaml = ProfilePath::Config.resolve(&entry.path());
183 if !profile_yaml.exists() {
184 return None;
185 }
186
187 let name = entry.file_name().to_string_lossy().to_string();
188 let profile = ProfileLoader::load_from_path(&profile_yaml).ok()?;
189
190 Some(DiscoveredProfile {
191 name,
192 path: profile_yaml,
193 profile,
194 })
195}
196
197pub fn generate_display_name(name: &str) -> String {
198 match name.to_lowercase().as_str() {
199 "dev" | "development" => "Development".to_string(),
200 "prod" | "production" => "Production".to_string(),
201 "staging" | "stage" => "Staging".to_string(),
202 "test" | "testing" => "Test".to_string(),
203 "local" => "Local Development".to_string(),
204 "cloud" => "Cloud".to_string(),
205 _ => capitalize_first(name),
206 }
207}
208
209fn capitalize_first(name: &str) -> String {
210 let mut chars = name.chars();
211 chars.next().map_or_else(String::new, |first| {
212 first.to_uppercase().chain(chars).collect()
213 })
214}
215
216pub fn generate_jwt_secret() -> String {
217 let mut rng = rng();
218 (0..64)
219 .map(|_| rng.sample(Alphanumeric))
220 .map(char::from)
221 .collect()
222}
223
224pub fn save_profile_yaml(profile: &Profile, path: &Path, header: Option<&str>) -> Result<()> {
225 if let Some(parent) = path.parent() {
226 std::fs::create_dir_all(parent)
227 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
228 }
229
230 let yaml = serde_yaml::to_string(profile).context("Failed to serialize profile")?;
231
232 let content = header.map_or_else(|| yaml.clone(), |h| format!("{}\n\n{}", h, yaml));
233
234 std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
235
236 Ok(())
237}