@@ -33,6 +33,11 @@ pub struct Config {
3333 pub clipboard : ClipboardCfg ,
3434 /// Agent settings.
3535 pub agent : AgentCfg ,
36+ /// Registered account profile (written by `vault register`). Skipped from
37+ /// the file until something is set, so an unregistered config carries no
38+ /// empty `[account]` table.
39+ #[ serde( skip_serializing_if = "AccountCfg::is_empty" ) ]
40+ pub account : AccountCfg ,
3641}
3742
3843/// `[clipboard]` table.
@@ -51,6 +56,31 @@ pub struct AgentCfg {
5156 pub idle_lock_secs : Option < u64 > ,
5257}
5358
59+ /// `[account]` table — the registered account, written by `vault register`
60+ /// and read by `login`/`unlock` to default `server`/`email`/`device_id`.
61+ #[ derive( Clone , Debug , Default , PartialEq , Eq , Deserialize , Serialize ) ]
62+ #[ serde( default , deny_unknown_fields) ]
63+ pub struct AccountCfg {
64+ /// Server origin (`https://vault.example.org`).
65+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
66+ pub server : Option < String > ,
67+ /// Account email (lower-cased on write).
68+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
69+ pub email : Option < String > ,
70+ /// Stable per-account device identifier (uuid v4), minted once at register
71+ /// time so the agent stops registering a fresh device each unlock.
72+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
73+ pub device_id : Option < String > ,
74+ }
75+
76+ impl AccountCfg {
77+ /// Whether no account field is set (drives skipping the table on write).
78+ #[ must_use]
79+ pub const fn is_empty ( & self ) -> bool {
80+ self . server . is_none ( ) && self . email . is_none ( ) && self . device_id . is_none ( )
81+ }
82+ }
83+
5484impl Config {
5585 /// Effective `clipboard.clear_secs`, if set.
5686 #[ must_use]
@@ -64,6 +94,23 @@ impl Config {
6494 self . agent . idle_lock_secs
6595 }
6696
97+ /// The registered account profile.
98+ #[ must_use]
99+ pub const fn account ( & self ) -> & AccountCfg {
100+ & self . account
101+ }
102+
103+ /// Record the account `server` + `email`, lower-casing the email and
104+ /// minting a `device_id` once (a pre-existing id is preserved so the
105+ /// account keeps its identity across re-registration).
106+ pub fn set_account ( & mut self , server : & str , email : & str ) {
107+ self . account . server = Some ( server. to_owned ( ) ) ;
108+ self . account . email = Some ( email. trim ( ) . to_lowercase ( ) ) ;
109+ if self . account . device_id . is_none ( ) {
110+ self . account . device_id = Some ( uuid:: Uuid :: new_v4 ( ) . to_string ( ) ) ;
111+ }
112+ }
113+
67114 /// Current value of `key` as a display string, `None` when unset. Errors
68115 /// (as the offending key) when `key` is not recognised.
69116 pub fn get ( & self , key : & str ) -> Result < Option < String > , String > {
@@ -262,6 +309,39 @@ mod tests {
262309 assert_eq ! ( back, c) ;
263310 }
264311
312+ #[ test]
313+ fn set_account_lowercases_email_and_mints_device_id_once ( ) {
314+ let mut c = Config :: default ( ) ;
315+ c. set_account ( "https://vault.example.org" , "Me@Example.org" ) ;
316+ assert_eq ! (
317+ c. account( ) . server. as_deref( ) ,
318+ Some ( "https://vault.example.org" )
319+ ) ;
320+ assert_eq ! ( c. account( ) . email. as_deref( ) , Some ( "me@example.org" ) ) ;
321+ let id = c. account ( ) . device_id . clone ( ) . expect ( "device_id minted" ) ;
322+ // Re-registering a different server/email keeps the same device id.
323+ c. set_account ( "https://other.example.org" , "Other@Example.org" ) ;
324+ assert_eq ! ( c. account( ) . device_id. as_deref( ) , Some ( id. as_str( ) ) ) ;
325+ assert_eq ! ( c. account( ) . email. as_deref( ) , Some ( "other@example.org" ) ) ;
326+ }
327+
328+ #[ test]
329+ fn account_round_trips_and_absent_until_set ( ) {
330+ // A config with nothing set must not emit an [account] table.
331+ let empty = toml:: to_string_pretty ( & Config :: default ( ) ) . expect ( "serialise" ) ;
332+ assert ! (
333+ !empty. contains( "[account]" ) ,
334+ "empty config grew [account]:\n {empty}"
335+ ) ;
336+
337+ let mut c = Config :: default ( ) ;
338+ c. set_account ( "https://vault.example.org" , "me@example.org" ) ;
339+ let text = toml:: to_string_pretty ( & c) . expect ( "serialise" ) ;
340+ assert ! ( text. contains( "[account]" ) , "account table missing:\n {text}" ) ;
341+ let back: Config = toml:: from_str ( & text) . expect ( "parse" ) ;
342+ assert_eq ! ( back. account( ) , c. account( ) ) ;
343+ }
344+
265345 #[ test]
266346 fn agent_args_emits_only_set_keys_in_order ( ) {
267347 let mut c = Config :: default ( ) ;
0 commit comments