Skip to main content

systemprompt_cli/commands/cloud/sync/admin_user/
discovery.rs

1//! Profile discovery for cross-profile admin-user sync.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use anyhow::Result;
7use std::path::PathBuf;
8use systemprompt_cloud::{ProfilePath, ProjectContext};
9use systemprompt_logging::CliService;
10
11use super::types::{ProfileDiscoveryResult, ProfileEntryResult, ProfileInfo, ProfileSkipReason};
12
13fn process_profile_entry(ctx: &ProjectContext, path: PathBuf) -> ProfileEntryResult {
14    if !path.is_dir() {
15        return ProfileEntryResult::NotDirectory;
16    }
17
18    let name = match path.file_name().and_then(|n| n.to_str()) {
19        Some(n) => n.to_owned(),
20        None => return ProfileEntryResult::Skip(ProfileSkipReason::InvalidDirectoryName { path }),
21    };
22
23    let profile_yaml = ctx.profile_path(&name, ProfilePath::Config);
24    let secrets_json = ctx.profile_path(&name, ProfilePath::Secrets);
25
26    if !profile_yaml.exists() {
27        return ProfileEntryResult::Skip(ProfileSkipReason::MissingConfig { path: profile_yaml });
28    }
29    if !secrets_json.exists() {
30        return ProfileEntryResult::Skip(ProfileSkipReason::MissingSecrets { path: secrets_json });
31    }
32
33    match load_database_url_from_secrets(&secrets_json, &name) {
34        Ok(db_url) => ProfileEntryResult::Valid(ProfileInfo {
35            name,
36            display_name: None,
37            database_url: Some(db_url),
38            tenant_id: None,
39            validation_mode: None,
40            credentials_path: None,
41            routing: None,
42            is_active: None,
43            session_status: None,
44        }),
45        Err(reason) => ProfileEntryResult::Skip(reason),
46    }
47}
48
49fn load_database_url_from_secrets(
50    secrets_json: &PathBuf,
51    profile_name: &str,
52) -> Result<String, ProfileSkipReason> {
53    let content =
54        std::fs::read_to_string(secrets_json).map_err(|e| ProfileSkipReason::SecretsReadError {
55            path: secrets_json.clone(),
56            error: e.to_string(),
57        })?;
58
59    let secrets: serde_json::Value =
60        serde_json::from_str(&content).map_err(|e| ProfileSkipReason::SecretsParseError {
61            path: secrets_json.clone(),
62            error: e.to_string(),
63        })?;
64
65    secrets
66        .get("database_url")
67        .and_then(|v| v.as_str())
68        .map(String::from)
69        .ok_or_else(|| ProfileSkipReason::MissingDatabaseUrl {
70            profile: profile_name.to_owned(),
71        })
72}
73
74pub fn discover_profiles() -> Result<ProfileDiscoveryResult> {
75    let ctx = ProjectContext::discover();
76    let profiles_dir = ctx.profiles_dir();
77
78    if !profiles_dir.exists() {
79        return Ok(ProfileDiscoveryResult {
80            profiles: Vec::new(),
81            skipped: Vec::new(),
82        });
83    }
84
85    let mut profiles = Vec::new();
86    let mut skipped = Vec::new();
87
88    for entry in std::fs::read_dir(&profiles_dir)? {
89        match process_profile_entry(&ctx, entry?.path()) {
90            ProfileEntryResult::Valid(info) => profiles.push(info),
91            ProfileEntryResult::Skip(reason) => skipped.push(reason),
92            ProfileEntryResult::NotDirectory => {},
93        }
94    }
95
96    Ok(ProfileDiscoveryResult { profiles, skipped })
97}
98
99pub fn print_discovery_summary(result: &ProfileDiscoveryResult, verbose: bool) {
100    let found_count = result.profiles.len();
101    let skipped_count = result.skipped.len();
102
103    if found_count > 0 {
104        CliService::info(&format!(
105            "Found {} profile(s) with database configuration",
106            found_count
107        ));
108    }
109
110    if skipped_count > 0 {
111        print_skipped_profiles(&result.skipped, verbose, skipped_count);
112    }
113}
114
115fn print_skipped_profiles(skipped: &[ProfileSkipReason], verbose: bool, count: usize) {
116    if verbose {
117        CliService::warning(&format!("Skipped {} profile(s):", count));
118        for reason in skipped {
119            print_skip_reason(reason);
120        }
121    } else {
122        CliService::info(&format!(
123            "Skipped {} profile(s) (use -v for details)",
124            count
125        ));
126    }
127}
128
129fn print_skip_reason(reason: &ProfileSkipReason) {
130    match reason {
131        ProfileSkipReason::MissingConfig { path } => {
132            CliService::warning(&format!("  - Missing config: {}", path.display()));
133        },
134        ProfileSkipReason::MissingSecrets { path } => {
135            CliService::warning(&format!("  - Missing secrets: {}", path.display()));
136        },
137        ProfileSkipReason::SecretsReadError { path, error } => {
138            CliService::warning(&format!("  - Cannot read {}: {}", path.display(), error));
139        },
140        ProfileSkipReason::SecretsParseError { path, error } => {
141            CliService::warning(&format!(
142                "  - Invalid JSON in {}: {}",
143                path.display(),
144                error
145            ));
146        },
147        ProfileSkipReason::MissingDatabaseUrl { profile } => {
148            CliService::warning(&format!("  - No database_url in profile '{}'", profile));
149        },
150        ProfileSkipReason::InvalidDirectoryName { path } => {
151            CliService::warning(&format!("  - Invalid directory name: {}", path.display()));
152        },
153    }
154}