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