Skip to main content

systemprompt_cli/shared/
profile.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use rand::distr::Alphanumeric;
5use rand::{RngExt, 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_owned()))
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(path_str.to_owned()))
93}
94
95fn expand_path(path_str: &str) -> PathBuf {
96    if path_str.starts_with('~') {
97        if let Some(home) = dirs::home_dir() {
98            return home.join(
99                path_str
100                    .strip_prefix("~/")
101                    .unwrap_or_else(|| &path_str[1..]),
102            );
103        }
104    }
105    PathBuf::from(path_str)
106}
107
108pub fn resolve_profile_with_data(
109    profile_input: &str,
110) -> Result<(PathBuf, Profile), ProfileResolutionError> {
111    let path = resolve_profile_input(profile_input)?;
112    let profile = ProfileLoader::load_from_path(&path)
113        .map_err(|e| ProfileResolutionError::DiscoveryFailed(anyhow::Error::from(e)))?;
114    Ok((path, profile))
115}
116
117fn resolve_profile_by_name(name: &str) -> Result<Option<PathBuf>, ProfileResolutionError> {
118    let ctx = ProjectContext::discover();
119    let profiles_dir = ctx.profiles_dir();
120    let target_dir = profiles_dir.join(name);
121    let config_path = ProfilePath::Config.resolve(&target_dir);
122
123    if config_path.exists() {
124        return Ok(Some(config_path));
125    }
126
127    let profiles = discover_profiles()?;
128    if let Some(found) = profiles.into_iter().find(|p| p.name == name) {
129        return Ok(Some(found.path));
130    }
131
132    {
133        let paths = crate::paths::ResolvedPaths::discover().sessions_dir();
134        if let Ok(store) = systemprompt_cloud::SessionStore::load_or_create(&paths) {
135            if let Some(session) = store.find_by_profile_name(name) {
136                if let Some(ref profile_path) = session.profile_path {
137                    if profile_path.exists() {
138                        return Ok(Some(profile_path.clone()));
139                    }
140                }
141            }
142        }
143    }
144
145    Ok(None)
146}
147
148#[derive(Debug)]
149pub struct DiscoveredProfile {
150    pub name: String,
151    pub path: PathBuf,
152    pub profile: Profile,
153}
154
155pub fn discover_profiles() -> Result<Vec<DiscoveredProfile>> {
156    let ctx = ProjectContext::discover();
157    let profiles_dir = ctx.profiles_dir();
158
159    if !profiles_dir.exists() {
160        return Ok(Vec::new());
161    }
162
163    let entries = std::fs::read_dir(&profiles_dir).with_context(|| {
164        format!(
165            "Failed to read profiles directory: {}",
166            profiles_dir.display()
167        )
168    })?;
169
170    let profiles = entries
171        .filter_map(std::result::Result::ok)
172        .filter(|e| e.path().is_dir())
173        .filter_map(|e| build_discovered_profile(&e))
174        .collect();
175
176    Ok(profiles)
177}
178
179fn build_discovered_profile(entry: &std::fs::DirEntry) -> Option<DiscoveredProfile> {
180    let profile_yaml = ProfilePath::Config.resolve(&entry.path());
181    if !profile_yaml.exists() {
182        return None;
183    }
184
185    let name = entry.file_name().to_string_lossy().to_string();
186    let profile = ProfileLoader::load_from_path(&profile_yaml)
187        .map_err(|e| tracing::warn!(profile = %name, error = %e, "Skipping unreadable profile during discovery"))
188        .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_owned(),
200        "prod" | "production" => "Production".to_owned(),
201        "staging" | "stage" => "Staging".to_owned(),
202        "test" | "testing" => "Test".to_owned(),
203        "local" => "Local Development".to_owned(),
204        "cloud" => "Cloud".to_owned(),
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_oauth_at_rest_pepper() -> 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}