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 env_override: Option<&str>,
44 from_session: Option<PathBuf>,
45) -> Result<PathBuf, ProfileResolutionError> {
46 if let Some(profile_input) = cli_override {
47 return resolve_profile_input(profile_input);
48 }
49
50 if let Some(path_str) = env_override {
51 return resolve_profile_input(path_str);
52 }
53
54 if let Some(path) = from_session.filter(|p| p.exists()) {
55 return Ok(path);
56 }
57
58 let mut profiles = discover_profiles()?;
59 match profiles.len() {
60 0 => Err(ProfileResolutionError::NoProfilesFound),
61 1 => Ok(profiles.swap_remove(0).path),
62 _ => Err(ProfileResolutionError::MultipleProfilesFound {
63 profiles: profiles.iter().map(|p| p.name.clone()).collect(),
64 }),
65 }
66}
67
68pub fn is_path_input(input: &str) -> bool {
69 let path = Path::new(input);
70 let has_yaml_extension = path
71 .extension()
72 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"));
73
74 input.contains(std::path::MAIN_SEPARATOR)
75 || input.contains('/')
76 || has_yaml_extension
77 || input.starts_with('.')
78 || input.starts_with('~')
79}
80
81fn resolve_profile_input(input: &str) -> Result<PathBuf, ProfileResolutionError> {
82 if is_path_input(input) {
83 return resolve_profile_from_path(input);
84 }
85 resolve_profile_by_name(input)?
86 .ok_or_else(|| ProfileResolutionError::ProfileNotFound(input.to_owned()))
87}
88
89pub fn resolve_profile_from_path(path_str: &str) -> Result<PathBuf, ProfileResolutionError> {
90 let path = expand_path(path_str);
91
92 if path.exists() {
93 return Ok(path);
94 }
95
96 let profile_yaml = path.join("profile.yaml");
97 if profile_yaml.exists() {
98 return Ok(profile_yaml);
99 }
100
101 Err(ProfileResolutionError::ProfileNotFound(path_str.to_owned()))
102}
103
104fn expand_path(path_str: &str) -> PathBuf {
105 if path_str.starts_with('~')
106 && let Some(home) = dirs::home_dir()
107 {
108 return home.join(
109 path_str
110 .strip_prefix("~/")
111 .unwrap_or_else(|| &path_str[1..]),
112 );
113 }
114 PathBuf::from(path_str)
115}
116
117pub fn resolve_profile_with_data(
118 profile_input: &str,
119) -> Result<(PathBuf, Profile), ProfileResolutionError> {
120 let path = resolve_profile_input(profile_input)?;
121 let profile = ProfileLoader::load_from_path(&path)
122 .map_err(|e| ProfileResolutionError::DiscoveryFailed(anyhow::Error::from(e)))?;
123 Ok((path, profile))
124}
125
126fn resolve_profile_by_name(name: &str) -> Result<Option<PathBuf>, ProfileResolutionError> {
127 let ctx = ProjectContext::discover();
128 let profiles_dir = ctx.profiles_dir();
129 let target_dir = profiles_dir.join(name);
130 let config_path = ProfilePath::Config.resolve(&target_dir);
131
132 if config_path.exists() {
133 return Ok(Some(config_path));
134 }
135
136 let profiles = discover_profiles()?;
137 if let Some(found) = profiles.into_iter().find(|p| p.name == name) {
138 return Ok(Some(found.path));
139 }
140
141 {
142 let paths = crate::paths::ResolvedPaths::discover().sessions_dir();
143 if let Ok(store) = systemprompt_cloud::SessionStore::load_or_create(&paths)
144 && let Some(session) = store.find_by_profile_name(name)
145 && let Some(ref profile_path) = session.profile_path
146 && profile_path.exists()
147 {
148 return Ok(Some(profile_path.clone()));
149 }
150 }
151
152 Ok(None)
153}
154
155#[derive(Debug)]
156pub struct DiscoveredProfile {
157 pub name: String,
158 pub path: PathBuf,
159 pub profile: Profile,
160}
161
162pub fn discover_profiles() -> Result<Vec<DiscoveredProfile>> {
163 let ctx = ProjectContext::discover();
164 let profiles_dir = ctx.profiles_dir();
165
166 if !profiles_dir.exists() {
167 return Ok(Vec::new());
168 }
169
170 let entries = std::fs::read_dir(&profiles_dir).with_context(|| {
171 format!(
172 "Failed to read profiles directory: {}",
173 profiles_dir.display()
174 )
175 })?;
176
177 let profiles = entries
178 .filter_map(std::result::Result::ok)
179 .filter(|e| e.path().is_dir())
180 .filter_map(|e| build_discovered_profile(&e))
181 .collect();
182
183 Ok(profiles)
184}
185
186fn build_discovered_profile(entry: &std::fs::DirEntry) -> Option<DiscoveredProfile> {
187 let profile_yaml = ProfilePath::Config.resolve(&entry.path());
188 if !profile_yaml.exists() {
189 return None;
190 }
191
192 let name = entry.file_name().to_string_lossy().to_string();
193 let profile = ProfileLoader::load_from_path(&profile_yaml)
194 .map_err(|e| tracing::warn!(profile = %name, error = %e, "Skipping unreadable profile during discovery"))
195 .ok()?;
196
197 Some(DiscoveredProfile {
198 name,
199 path: profile_yaml,
200 profile,
201 })
202}
203
204pub fn generate_oauth_at_rest_pepper() -> String {
205 let mut rng = rng();
206 (0..64)
207 .map(|_| rng.sample(Alphanumeric))
208 .map(char::from)
209 .collect()
210}
211
212pub fn save_profile_yaml(profile: &Profile, path: &Path, header: Option<&str>) -> Result<()> {
213 if let Some(parent) = path.parent() {
214 std::fs::create_dir_all(parent)
215 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
216 }
217
218 let yaml = serde_yaml::to_string(profile).context("Failed to serialize profile")?;
219
220 let content = header.map_or_else(|| yaml.clone(), |h| format!("{}\n\n{}", h, yaml));
221
222 std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
223
224 Ok(())
225}