Skip to main content

ignition_core/actions/
profile.rs

1//! `profile add/list/use` actions — pure config operations, no printing,
2//! serde models out (declaration order = golden field order).
3
4use std::path::Path;
5
6use serde::Serialize;
7
8use crate::config::{self, AuthRef, Config, Profile};
9use crate::error::CoreError;
10
11/// Result of `profile add` (declaration order = golden field order).
12#[derive(Debug, Serialize)]
13pub struct ProfileAddResult {
14    /// The profile's name.
15    pub name: String,
16    /// Optional display label (CORE-01) — absent from JSON when unset.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub label: Option<String>,
19    /// Gateway base URL (normalized).
20    pub url: String,
21    /// Safe credential kind string ("token_env"/"keyring"/"basic").
22    pub auth_kind: &'static str,
23    /// Whether this profile is the active one after the add.
24    pub active: bool,
25}
26
27/// One `profile list` row — `auth_kind` is a safe kind string, NEVER a
28/// secret or env value.
29#[derive(Debug, Serialize)]
30pub struct ProfileSummary {
31    /// Profile name.
32    pub name: String,
33    /// Optional display label (CORE-01) — absent from JSON when unset
34    /// (mirrors [`Profile::label`]).
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub label: Option<String>,
37    /// Gateway base URL (normalized).
38    pub url: String,
39    /// Safe credential kind string.
40    pub auth_kind: &'static str,
41}
42
43/// Result of `profile list`.
44#[derive(Debug, Serialize)]
45pub struct ProfileListResult {
46    /// The config's active profile name.
47    pub active: Option<String>,
48    /// Profiles in `BTreeMap` (name-sorted) order — deterministic goldens.
49    pub profiles: Vec<ProfileSummary>,
50}
51
52/// Result of `profile use`.
53#[derive(Debug, Serialize)]
54pub struct ProfileUseResult {
55    /// The newly active profile name.
56    pub active: String,
57}
58
59/// Add (or overwrite) a profile and persist the config (0600).
60///
61/// `label` (CORE-01's display label) is stored on the profile when given;
62/// `None` when `--label` was absent.
63pub fn add(
64    config_path: &Path,
65    name: &str,
66    url_str: &str,
67    label: Option<&str>,
68    auth: AuthRef,
69    set_active: bool,
70) -> Result<ProfileAddResult, CoreError> {
71    let url = url::Url::parse(url_str).map_err(|err| CoreError::ConfigInvalid {
72        reason: format!("invalid URL for profile {name:?}: {url_str} ({err})"),
73    })?;
74    let mut config = config::load(config_path)?;
75    if config
76        .profiles
77        .insert(
78            name.to_string(),
79            Profile {
80                url: url.clone(),
81                label: label.map(str::to_string),
82                ssl_verify: true,
83                auth: auth.clone(),
84                webdev_secret: None,
85                poll_interval_secs: None,
86            },
87        )
88        .is_some()
89    {
90        tracing::warn!(profile = name, "overwriting existing profile");
91    }
92    if set_active {
93        config.active = Some(name.to_string());
94    }
95    config::save(config_path, &config)?;
96
97    Ok(ProfileAddResult {
98        name: name.to_string(),
99        label: label.map(str::to_string),
100        url: url.to_string(),
101        auth_kind: auth.kind(),
102        active: config.active.as_deref() == Some(name),
103    })
104}
105
106/// List profiles (name-sorted via `BTreeMap` order).
107pub fn list(config: &Config) -> ProfileListResult {
108    ProfileListResult {
109        active: config.active.clone(),
110        profiles: config
111            .profiles
112            .iter()
113            .map(|(name, profile)| ProfileSummary {
114                name: name.clone(),
115                label: profile.label.clone(),
116                url: profile.url.to_string(),
117                auth_kind: profile.auth.kind(),
118            })
119            .collect(),
120    }
121}
122
123/// Switch the active profile. A missing config file behaves like an unknown
124/// profile (`load` yields the empty default, so the name simply is not in
125/// the known list) — exit 3, same class.
126pub fn use_profile(config_path: &Path, name: &str) -> Result<ProfileUseResult, CoreError> {
127    let mut config = config::load(config_path)?;
128    if !config.profiles.contains_key(name) {
129        return Err(CoreError::ProfileNotFound {
130            name: name.to_string(),
131            known: config.profiles.keys().cloned().collect(),
132        });
133    }
134    config.active = Some(name.to_string());
135    config::save(config_path, &config)?;
136    Ok(ProfileUseResult {
137        active: name.to_string(),
138    })
139}
140
141#[cfg(test)]
142mod tests {
143    use super::{add, list, use_profile};
144    use crate::config::{AuthRef, load};
145    use crate::error::CoreError;
146
147    fn temp_config_path() -> (tempfile::TempDir, std::path::PathBuf) {
148        let dir = tempfile::tempdir().expect("tempdir");
149        let path = dir.path().join("config.toml");
150        (dir, path)
151    }
152
153    /// Add → list → use round-trip against a real (temp) config file,
154    /// including the label skip and active tracking.
155    #[test]
156    fn add_list_use_round_trip() {
157        let (_dir, path) = temp_config_path();
158
159        let added = add(
160            &path,
161            "dev",
162            "http://localhost:9088",
163            Some("Dev rig"),
164            AuthRef::TokenEnv {
165                token_env: "IGNITION_TOKEN".into(),
166            },
167            true,
168        )
169        .expect("add dev");
170        assert!(added.active, "--active sets it");
171        assert_eq!(added.url, "http://localhost:9088/");
172
173        add(
174            &path,
175            "prod",
176            "https://gw.example.com:8443",
177            None,
178            AuthRef::Keyring {
179                keyring: "profile:prod".into(),
180            },
181            false,
182        )
183        .expect("add prod");
184
185        let listed = list(&load(&path).expect("load"));
186        assert_eq!(listed.active.as_deref(), Some("dev"));
187        assert_eq!(listed.profiles.len(), 2);
188        assert_eq!(listed.profiles[0].name, "dev", "BTreeMap order");
189        assert_eq!(listed.profiles[0].label.as_deref(), Some("Dev rig"));
190        assert_eq!(listed.profiles[0].auth_kind, "token_env");
191        assert_eq!(listed.profiles[1].label, None);
192        assert_eq!(listed.profiles[1].auth_kind, "keyring");
193
194        let used = use_profile(&path, "prod").expect("use prod");
195        assert_eq!(used.active, "prod");
196        assert_eq!(load(&path).expect("load").active.as_deref(), Some("prod"));
197    }
198
199    /// Invalid URL is a config-class error (exit 3); unknown `use` target
200    /// carries the known list.
201    #[test]
202    fn add_rejects_invalid_url_and_use_rejects_unknown() {
203        let (_dir, path) = temp_config_path();
204
205        let err = add(&path, "dev", "not a url", None, AuthRef::default(), false)
206            .expect_err("invalid URL rejected");
207        assert!(matches!(err, CoreError::ConfigInvalid { .. }));
208        assert_eq!(err.exit_code(), 3);
209
210        add(
211            &path,
212            "dev",
213            "http://localhost:9088",
214            None,
215            AuthRef::default(),
216            false,
217        )
218        .expect("add dev");
219        let err = use_profile(&path, "nope").expect_err("unknown profile");
220        match err {
221            CoreError::ProfileNotFound {
222                ref name,
223                ref known,
224            } => {
225                assert_eq!(name, "nope");
226                assert_eq!(known, &vec!["dev".to_string()]);
227            }
228            other => panic!("wrong error: {other}"),
229        }
230    }
231}