From 64275bc64fc394a79d1c147a34f8681e4a3dffa8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 8 May 2026 21:48:35 -0400 Subject: [PATCH] refactor(cli): move cmd_edit family into commands/edit.rs --- crates/relicario-cli/src/commands/edit.rs | 172 ++++++++++++++++++++++ crates/relicario-cli/src/commands/mod.rs | 1 + crates/relicario-cli/src/main.rs | 170 +-------------------- 3 files changed, 176 insertions(+), 167 deletions(-) create mode 100644 crates/relicario-cli/src/commands/edit.rs diff --git a/crates/relicario-cli/src/commands/edit.rs b/crates/relicario-cli/src/commands/edit.rs new file mode 100644 index 0000000..a480021 --- /dev/null +++ b/crates/relicario-cli/src/commands/edit.rs @@ -0,0 +1,172 @@ +//! `relicario edit ` — interactive per-type field editing with history capture. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::parse::base32_decode_lenient; +use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno}; + +pub fn cmd_edit(query: String, totp_qr: Option) -> Result<()> { + use relicario_core::time::now_unix; + use relicario_core::ItemCore; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = super::resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let _ = entry; + let mut item = vault.load_item(&id)?; + + eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.", + item.title, item.id.as_str()); + + if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; } + if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); } + if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? { + item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + } + + let history = &mut item.field_history; + match &mut item.core { + ItemCore::Login(l) => edit_login(l, history, totp_qr)?, + ItemCore::SecureNote(n) => edit_secure_note(n, history)?, + ItemCore::Identity(i) => edit_identity(i)?, + ItemCore::Card(c) => edit_card(c, history)?, + ItemCore::Key(k) => edit_key(k, history)?, + ItemCore::Document(_) => edit_document_message(), + ItemCore::Totp(t) => edit_totp(t, history)?, + } + + item.modified = now_unix(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + crate::refresh_groups_cache(vault.root(), &manifest); + super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Updated {}", item.id.as_str()); + Ok(()) +} + +// --- Per-type edit handlers. Each mutates its core slice in place; the ones +// that touch history-tracked fields take the item's field_history map so +// they can record the prior value alongside the change. + +type FieldHistory = std::collections::HashMap< + relicario_core::FieldId, + Vec, +>; + +fn edit_login( + l: &mut relicario_core::item_types::LoginCore, + history: &mut FieldHistory, + totp_qr: Option, +) -> Result<()> { + use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind}; + use zeroize::Zeroizing; + if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); } + if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? { + l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?); + } + if prompt_yesno("Change password?")? { + let old = l.password.clone(); + l.password = Some(Zeroizing::new(prompt_secret("New password: ")?)); + if let Some(old_pw) = old { + push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string())); + } + } + if let Some(path) = totp_qr { + let secret_b32 = crate::helpers::decode_totp_qr(&path)?; + let secret_bytes = base32_decode_lenient(&secret_b32)?; + l.totp = Some(TotpConfig { + secret: Zeroizing::new(secret_bytes), + algorithm: TotpAlgorithm::Sha1, + digits: 6, + period_seconds: 30, + kind: TotpKind::Totp, + }); + eprintln!("TOTP secret set from QR image."); + } + Ok(()) +} + +fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> { + use zeroize::Zeroizing; + if prompt_yesno("Edit body?")? { + let old = n.body.clone(); + eprintln!("Enter new body; end with Ctrl-D:"); + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + n.body = Zeroizing::new(s); + push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string())); + } + Ok(()) +} + +fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> { + if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); } + if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); } + if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); } + Ok(()) +} + +fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> { + use zeroize::Zeroizing; + if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); } + if prompt_yesno("Change card number?")? { + let old = c.number.clone(); + c.number = Some(Zeroizing::new(prompt_secret("New number: ")?)); + if let Some(o) = old { + push_history(history, "card_number", Zeroizing::new(o.as_str().to_string())); + } + } + Ok(()) +} + +fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> { + use zeroize::Zeroizing; + if prompt_yesno("Replace key material?")? { + eprintln!("Paste new key material; end with Ctrl-D:"); + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + let old = k.key_material.clone(); + k.key_material = Zeroizing::new(s); + push_history(history, "key_material", Zeroizing::new(old.as_str().to_string())); + } + Ok(()) +} + +fn edit_document_message() { + eprintln!("Document items: use `relicario attach` / `relicario extract` instead."); +} + +fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> { + use zeroize::Zeroizing; + if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); } + if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); } + if prompt_yesno("Change TOTP secret?")? { + let old_b32 = data_encoding::BASE32.encode(&t.config.secret); + let new_b32 = prompt_secret("New TOTP secret (base32): ")?; + let new_bytes = base32_decode_lenient(&new_b32)?; + t.config.secret = Zeroizing::new(new_bytes); + push_history(history, "totp_secret", Zeroizing::new(old_b32)); + } + Ok(()) +} + +fn push_history( + history: &mut std::collections::HashMap>, + synthetic_key: &str, + old_value: zeroize::Zeroizing, +) { + use relicario_core::item::FieldHistoryEntry; + use relicario_core::time::now_unix; + // Synthetic FieldId for core-level fields — stable per-item (prefixed so + // custom-field UUIDs can't collide). + let fid = relicario_core::FieldId(format!("core:{synthetic_key}")); + history.entry(fid).or_default().push(FieldHistoryEntry { + value: old_value, + replaced_at: now_unix(), + }); +} diff --git a/crates/relicario-cli/src/commands/mod.rs b/crates/relicario-cli/src/commands/mod.rs index 98198d1..57cc995 100644 --- a/crates/relicario-cli/src/commands/mod.rs +++ b/crates/relicario-cli/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod attach; pub mod backup; pub mod device; +pub mod edit; pub mod generate; pub mod get; pub mod import; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index acbea28..425f1c7 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -16,9 +16,9 @@ use anyhow::{Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; -use crate::commands::{commit_paths, resolve_query}; +use crate::commands::commit_paths; use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year}; -use crate::prompt::{prompt, prompt_keep, prompt_keep_opt, prompt_optional, prompt_secret, prompt_yesno}; +use crate::prompt::{prompt, prompt_optional, prompt_secret}; #[derive(Parser)] #[command( @@ -431,7 +431,7 @@ fn main() -> Result<()> { Commands::Add { kind } => cmd_add(kind), Commands::Get { query, show, copy } => commands::get::cmd_get(query, show, copy), Commands::List { r#type, group, tag, trashed } => commands::list::cmd_list(r#type, group, tag, trashed), - Commands::Edit { query, totp_qr } => cmd_edit(query, totp_qr), + Commands::Edit { query, totp_qr } => commands::edit::cmd_edit(query, totp_qr), Commands::History { query, show, field } => commands::list::cmd_history(query, show, field), Commands::Rm { query } => commands::trash::cmd_rm(query), Commands::Restore { query } => commands::trash::cmd_restore(query), @@ -815,168 +815,4 @@ fn build_totp_item( Ok(item) } -fn cmd_edit(query: String, totp_qr: Option) -> Result<()> { - use relicario_core::time::now_unix; - use relicario_core::ItemCore; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let id = entry.id.clone(); - let _ = entry; - let mut item = vault.load_item(&id)?; - - eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.", - item.title, item.id.as_str()); - - if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; } - if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); } - if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? { - item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); - } - - let history = &mut item.field_history; - match &mut item.core { - ItemCore::Login(l) => edit_login(l, history, totp_qr)?, - ItemCore::SecureNote(n) => edit_secure_note(n, history)?, - ItemCore::Identity(i) => edit_identity(i)?, - ItemCore::Card(c) => edit_card(c, history)?, - ItemCore::Key(k) => edit_key(k, history)?, - ItemCore::Document(_) => edit_document_message(), - ItemCore::Totp(t) => edit_totp(t, history)?, - } - - item.modified = now_unix(); - vault.save_item(&item)?; - manifest.upsert(&item); - vault.save_manifest(&manifest)?; - refresh_groups_cache(vault.root(), &manifest); - commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), - &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; - eprintln!("Updated {}", item.id.as_str()); - Ok(()) -} - -// --- Per-type edit handlers. Each mutates its core slice in place; the ones -// that touch history-tracked fields take the item's field_history map so -// they can record the prior value alongside the change. - -type FieldHistory = std::collections::HashMap< - relicario_core::FieldId, - Vec, ->; - -fn edit_login( - l: &mut relicario_core::item_types::LoginCore, - history: &mut FieldHistory, - totp_qr: Option, -) -> Result<()> { - use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind}; - use zeroize::Zeroizing; - if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); } - if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? { - l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?); - } - if prompt_yesno("Change password?")? { - let old = l.password.clone(); - l.password = Some(Zeroizing::new(prompt_secret("New password: ")?)); - if let Some(old_pw) = old { - push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string())); - } - } - if let Some(path) = totp_qr { - let secret_b32 = crate::helpers::decode_totp_qr(&path)?; - let secret_bytes = base32_decode_lenient(&secret_b32)?; - l.totp = Some(TotpConfig { - secret: Zeroizing::new(secret_bytes), - algorithm: TotpAlgorithm::Sha1, - digits: 6, - period_seconds: 30, - kind: TotpKind::Totp, - }); - eprintln!("TOTP secret set from QR image."); - } - Ok(()) -} - -fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> { - use zeroize::Zeroizing; - if prompt_yesno("Edit body?")? { - let old = n.body.clone(); - eprintln!("Enter new body; end with Ctrl-D:"); - let mut s = String::new(); - std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; - n.body = Zeroizing::new(s); - push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string())); - } - Ok(()) -} - -fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> { - if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); } - if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); } - if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); } - Ok(()) -} - -fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> { - use zeroize::Zeroizing; - if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); } - if prompt_yesno("Change card number?")? { - let old = c.number.clone(); - c.number = Some(Zeroizing::new(prompt_secret("New number: ")?)); - if let Some(o) = old { - push_history(history, "card_number", Zeroizing::new(o.as_str().to_string())); - } - } - Ok(()) -} - -fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> { - use zeroize::Zeroizing; - if prompt_yesno("Replace key material?")? { - eprintln!("Paste new key material; end with Ctrl-D:"); - let mut s = String::new(); - std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; - let old = k.key_material.clone(); - k.key_material = Zeroizing::new(s); - push_history(history, "key_material", Zeroizing::new(old.as_str().to_string())); - } - Ok(()) -} - -fn edit_document_message() { - eprintln!("Document items: use `relicario attach` / `relicario extract` instead."); -} - -fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> { - use zeroize::Zeroizing; - if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); } - if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); } - if prompt_yesno("Change TOTP secret?")? { - let old_b32 = data_encoding::BASE32.encode(&t.config.secret); - let new_b32 = prompt_secret("New TOTP secret (base32): ")?; - let new_bytes = base32_decode_lenient(&new_b32)?; - t.config.secret = Zeroizing::new(new_bytes); - push_history(history, "totp_secret", Zeroizing::new(old_b32)); - } - Ok(()) -} - -fn push_history( - history: &mut std::collections::HashMap>, - synthetic_key: &str, - old_value: zeroize::Zeroizing, -) { - use relicario_core::item::FieldHistoryEntry; - use relicario_core::time::now_unix; - // Synthetic FieldId for core-level fields — stable per-item (prefixed so - // custom-field UUIDs can't collide). - let fid = relicario_core::FieldId(format!("core:{synthetic_key}")); - history.entry(fid).or_default().push(FieldHistoryEntry { - value: old_value, - replaced_at: now_unix(), - }); -} -