Skip to main content

oboron_cli_core/
commands.rs

1//! Command-handler implementations shared by the oboron-protocol CLIs.
2//!
3//! Each handler is parameterized over a [`CliInfo`] supplying the
4//! per-binary bits (name shown in user-facing hints, default scheme,
5//! default encoding). Key generation, where needed, is passed in as
6//! a closure so this crate stays free of any dependency on `oboron`
7//! or `obcrypt`.
8
9use anyhow::{anyhow, Context, Result};
10
11use crate::config::{load_config, save_config, Config};
12use crate::key::normalize_key_to_hex;
13use crate::paths::{config_path, profile_path};
14use crate::profile::{
15    delete_profile, list_profiles, load_profile, load_profile_key,
16    rename_profile, save_profile, validate_profile_name, KeyProfile,
17};
18
19/// Load `<name>`'s key as canonical hex, auto-migrating any legacy
20/// base64 in place and printing a stderr notice if a migration ran.
21///
22/// Wraps [`crate::profile::load_profile_key`]: that function does the
23/// mechanical migration; this one is the user-facing CLI helper that
24/// also reports the migration. Used both by the command handlers in
25/// this module and directly by each binary's key-resolution paths.
26pub fn load_profile_key_with_notice(name: &str) -> Result<String> {
27    let loaded = load_profile_key(name)?;
28    if let Some(backup) = &loaded.migrated_backup {
29        eprintln!(
30            "notice: profile '{name}' had a legacy base64 key; \
31             rewrote it as canonical hex (backup: {})",
32            backup.display(),
33        );
34        eprintln!(
35            "        base64 keys are deprecated and will be removed before \
36             oboron 1.0."
37        );
38    }
39    Ok(loaded.hex)
40}
41
42/// Binary-specific bits a command handler needs to know.
43pub struct CliInfo<'a> {
44    /// Binary name as users type it on the shell — `"ob"` or `"obcrypt"`.
45    pub binary_name: &'a str,
46    /// Default scheme written into `config.json` on `init` / `activate`.
47    pub default_scheme: &'a str,
48    /// Default encoding written into `config.json`. `None` for binaries
49    /// that have no encoding layer (obcrypt).
50    pub default_encoding: Option<&'a str>,
51}
52
53/// Load `config.json`, erroring with a binary-specific hint if missing.
54fn require_config(info: &CliInfo<'_>) -> Result<Config> {
55    load_config()?.ok_or_else(|| {
56        let p = config_path()
57            .map(|p| p.display().to_string())
58            .unwrap_or_else(|_| "~/.oboron/config.json".into());
59        anyhow!(
60            "config not found at {p}\nHint: run '{} init' to create one",
61            info.binary_name
62        )
63    })
64}
65
66pub fn init_command(
67    info: &CliInfo<'_>,
68    name: &str,
69    generate_key: impl FnOnce() -> String,
70) -> Result<()> {
71    validate_profile_name(name)?;
72    let path = profile_path(name)?;
73    if path.exists() {
74        eprintln!("❌ Error: Profile '{name}' already exists");
75        eprintln!();
76        eprintln!("'{} init' will not overwrite an existing profile.", info.binary_name);
77        eprintln!();
78        eprintln!("Options:");
79        eprintln!("  {} init <new-profile-name>", info.binary_name);
80        eprintln!("  {} profile delete {name}", info.binary_name);
81        eprintln!("  {} profile create <profile-name>", info.binary_name);
82        anyhow::bail!("profile '{name}' already exists");
83    }
84
85    let key = generate_key();
86    save_profile(name, &KeyProfile { key: Some(key.clone()) })?;
87    save_config(&Config {
88        profile: Some(name.to_string()),
89        scheme: Some(info.default_scheme.to_string()),
90        encoding: info.default_encoding.map(str::to_string),
91    })?;
92
93    let cfg_path = config_path()?;
94    println!("✓ Configuration saved to {}", cfg_path.display());
95    println!("\nYour profile '{name}':");
96    println!("  Default scheme:   {}", info.default_scheme);
97    if let Some(enc) = info.default_encoding {
98        println!("  Default encoding: {enc}");
99    }
100    println!("  Key:              {key}");
101    println!("\n⚠️  Keep this key secure! Anyone with it can decode your data.");
102
103    Ok(())
104}
105
106pub fn config_show_command(info: &CliInfo<'_>) -> Result<()> {
107    let config = require_config(info)?;
108    let profile_name = config
109        .profile
110        .as_deref()
111        .ok_or_else(|| anyhow!("config has no active profile"))?;
112    // Eager-migrate any legacy base64 key in the active profile —
113    // display always shows canonical hex.
114    let key_hex = load_profile_key_with_notice(profile_name)?;
115
116    println!("Current configuration:");
117    println!("  Profile:  {profile_name}");
118    if let Some(s) = &config.scheme {
119        println!("  Scheme:   {s}");
120    }
121    if let Some(e) = &config.encoding {
122        println!("  Encoding: {e}");
123    }
124    println!("  Key:      {key_hex}");
125
126    Ok(())
127}
128
129pub fn config_set_command(
130    info: &CliInfo<'_>,
131    scheme: Option<String>,
132    encoding: Option<String>,
133    profile: Option<String>,
134) -> Result<()> {
135    let mut config = load_config()?.unwrap_or_else(|| Config {
136        profile: Some("default".to_string()),
137        scheme: Some(info.default_scheme.to_string()),
138        encoding: info.default_encoding.map(str::to_string),
139    });
140
141    if let Some(s) = scheme {
142        config.scheme = Some(s);
143    }
144    if let Some(e) = encoding {
145        config.encoding = Some(e);
146    }
147    if let Some(p) = profile {
148        config.profile = Some(p);
149    }
150
151    save_config(&config)?;
152    println!("✓ Configuration updated");
153    if let Some(p) = &config.profile {
154        println!("  Profile:  {p}");
155    }
156    if let Some(s) = &config.scheme {
157        println!("  Scheme:   {s}");
158    }
159    if let Some(e) = &config.encoding {
160        println!("  Encoding: {e}");
161    }
162    Ok(())
163}
164
165pub fn profile_list_command(info: &CliInfo<'_>) -> Result<()> {
166    let profiles = list_profiles()?;
167    if profiles.is_empty() {
168        println!("No profiles found. Run '{} init' to create one.", info.binary_name);
169        return Ok(());
170    }
171
172    let active = load_config().ok().flatten().and_then(|c| c.profile);
173    println!("Available profiles:");
174    for p in profiles {
175        let marker = if Some(p.as_str()) == active.as_deref() {
176            " (active)"
177        } else {
178            ""
179        };
180        println!("  {p}{marker}");
181    }
182    Ok(())
183}
184
185pub fn profile_show_command(info: &CliInfo<'_>, name: Option<&str>) -> Result<()> {
186    let profile_name = match name {
187        Some(n) => n.to_string(),
188        None => require_config(info)?
189            .profile
190            .ok_or_else(|| anyhow!("config has no active profile"))?,
191    };
192    let key_hex = load_profile_key_with_notice(&profile_name)?;
193    println!("Profile '{profile_name}':");
194    println!("  Key: {key_hex}");
195    Ok(())
196}
197
198pub fn profile_activate_command(info: &CliInfo<'_>, name: &str) -> Result<()> {
199    validate_profile_name(name)?;
200    load_profile(name)?; // ensure exists
201
202    let mut cfg = load_config()?.unwrap_or_default();
203    cfg.profile = Some(name.to_string());
204    if cfg.scheme.is_none() {
205        cfg.scheme = Some(info.default_scheme.to_string());
206    }
207    if cfg.encoding.is_none() {
208        cfg.encoding = info.default_encoding.map(str::to_string);
209    }
210    save_config(&cfg)?;
211    println!("✓ Activated profile '{name}'");
212    Ok(())
213}
214
215pub fn profile_create_command(
216    name: &str,
217    key: Option<&str>,
218    generate_key: impl FnOnce() -> String,
219) -> Result<()> {
220    validate_profile_name(name)?;
221    let key_str = if let Some(k) = key {
222        normalize_key_to_hex(k).context("invalid --key")?
223    } else {
224        generate_key()
225    };
226    save_profile(name, &KeyProfile { key: Some(key_str.clone()) })?;
227    println!("✓ Created profile '{name}'");
228    println!("  Key: {key_str}");
229    println!("\n⚠️  Keep this profile secure!");
230    Ok(())
231}
232
233pub fn profile_delete_command(info: &CliInfo<'_>, name: &str) -> Result<()> {
234    validate_profile_name(name)?;
235    if let Ok(Some(cfg)) = load_config() {
236        if cfg.profile.as_deref() == Some(name) {
237            eprintln!("❌ Error: Cannot delete active profile '{name}'");
238            eprintln!();
239            eprintln!("Activate a different profile first:");
240            eprintln!("  {} profile activate <other-profile-name>", info.binary_name);
241            anyhow::bail!("cannot delete active profile '{name}'");
242        }
243    }
244
245    let backup = delete_profile(name)?;
246    println!("✓ Deleted profile '{name}'");
247    if let Some(p) = backup {
248        println!("  Backup saved to: {}", p.display());
249    }
250    Ok(())
251}
252
253pub fn profile_rename_command(old_name: &str, new_name: &str) -> Result<()> {
254    let backup = rename_profile(old_name, new_name)?;
255
256    if let Ok(Some(mut cfg)) = load_config() {
257        if cfg.profile.as_deref() == Some(old_name) {
258            cfg.profile = Some(new_name.to_string());
259            save_config(&cfg)?;
260            println!(
261                "✓ Renamed profile '{old_name}' to '{new_name}' (active profile updated)"
262            );
263        } else {
264            println!("✓ Renamed profile '{old_name}' to '{new_name}'");
265        }
266    } else {
267        println!("✓ Renamed profile '{old_name}' to '{new_name}'");
268    }
269
270    if let Some(p) = backup {
271        println!("  Backup saved to: {}", p.display());
272    }
273    Ok(())
274}
275
276pub fn profile_set_command(name: &str, key: &str) -> Result<()> {
277    validate_profile_name(name)?;
278    let mut profile = load_profile(name)?;
279    profile.key = Some(normalize_key_to_hex(key).context("invalid --key")?);
280    save_profile(name, &profile)?;
281    println!("✓ Updated profile '{name}'");
282    Ok(())
283}
284
285pub fn key_command(info: &CliInfo<'_>, profile_name: Option<&str>) -> Result<()> {
286    let cfg = load_config().ok().flatten();
287    let active = cfg.as_ref().and_then(|c| c.profile.as_deref());
288
289    let prof = profile_name.or(active).ok_or_else(|| {
290        anyhow!(
291            "no profile given and no active profile in config; \
292             run '{} init' or pass --profile",
293            info.binary_name
294        )
295    })?;
296    let key_hex = load_profile_key_with_notice(prof)?;
297    println!("{key_hex}");
298    Ok(())
299}