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