1use crate::{codename, types};
4
5const PUBKEY_DISPLAY_LEN: usize = 12;
7
8#[derive(Debug, Default)]
10pub struct InfoEntry {
11 pub key: String,
12 pub description: String,
13 pub example: Option<String>,
14 pub tags: Vec<String>,
15 pub rotation_interval_days: Option<u32>,
17 pub expires_at: Option<String>,
19 pub scoped_recipients: Vec<String>,
21}
22
23pub fn lifecycle_segment(rotation_interval_days: Option<u32>, expires_at: Option<&str>) -> String {
28 let mut parts = Vec::new();
29 if let Some(days) = rotation_interval_days {
30 parts.push(format!("rotate {days}d"));
31 }
32 if let Some(ts) = expires_at {
33 let date = ts.split('T').next().unwrap_or(ts);
34 parts.push(format!("expires {date}"));
35 }
36 parts.join(" ")
37}
38
39#[derive(Debug)]
41pub struct VaultInfo {
42 pub vault_name: String,
43 pub codename: String,
44 pub repo: String,
45 pub created: String,
46 pub recipient_count: usize,
47 pub recipient_names: Vec<String>,
49 pub self_name: Option<String>,
51 pub self_pubkey: Option<String>,
53 pub entries: Vec<InfoEntry>,
54}
55
56pub fn vault_info(
62 raw_bytes: &[u8],
63 tags: &[String],
64 secret_key: Option<&str>,
65) -> Result<VaultInfo, String> {
66 let vault: types::Vault = serde_json::from_slice(raw_bytes).map_err(|e| e.to_string())?;
67
68 let codename = codename::from_bytes(raw_bytes);
69
70 let filtered: Vec<(&String, &types::SchemaEntry)> = if tags.is_empty() {
72 vault.schema.iter().collect()
73 } else {
74 vault
75 .schema
76 .iter()
77 .filter(|(_, e)| e.tags.iter().any(|t| tags.contains(t)))
78 .collect()
79 };
80
81 let self_pubkey = secret_key.and_then(|sk| {
83 let identity = crate::crypto::parse_identity(sk).ok()?;
84 identity.pubkey_string().ok()
85 });
86
87 let meta_data = secret_key.and_then(|sk| {
89 let identity = crate::crypto::parse_identity(sk).ok()?;
90 crate::decrypt_meta(&vault, &identity)
91 });
92
93 let entries = filtered
94 .iter()
95 .map(|(key, entry)| {
96 let scoped_recipients = if let Some(ref meta) = meta_data {
97 vault
98 .secrets
99 .get(key.as_str())
100 .map(|s| {
101 s.private
102 .keys()
103 .map(|pk| {
104 meta.recipients.get(pk).cloned().unwrap_or_else(|| {
105 pk.chars().take(PUBKEY_DISPLAY_LEN).collect::<String>()
106 + "\u{2026}"
107 })
108 })
109 .collect()
110 })
111 .unwrap_or_default()
112 } else {
113 vec![]
114 };
115
116 InfoEntry {
117 key: (*key).clone(),
118 description: entry.description.clone(),
119 example: entry.example.clone(),
120 tags: entry.tags.clone(),
121 rotation_interval_days: entry.rotation_interval_days,
122 expires_at: entry.expires_at.clone(),
123 scoped_recipients,
124 }
125 })
126 .collect();
127
128 let recipient_names = if let Some(ref meta) = meta_data {
130 vault
131 .recipients
132 .iter()
133 .map(|pk| {
134 meta.recipients.get(pk).cloned().unwrap_or_else(|| {
135 pk.chars().take(PUBKEY_DISPLAY_LEN).collect::<String>() + "\u{2026}"
136 })
137 })
138 .collect()
139 } else {
140 vec![]
141 };
142
143 let self_name = self_pubkey.as_ref().and_then(|pk| {
145 meta_data
146 .as_ref()
147 .and_then(|m| m.recipients.get(pk).cloned())
148 });
149
150 Ok(VaultInfo {
151 vault_name: vault.vault_name.clone(),
152 codename,
153 repo: vault.repo.clone(),
154 created: vault.created.clone(),
155 recipient_count: vault.recipients.len(),
156 recipient_names,
157 self_name,
158 self_pubkey,
159 entries,
160 })
161}
162
163pub fn format_info_lines(info: &VaultInfo, has_meta: bool) -> Vec<String> {
166 let mut lines = Vec::new();
167
168 lines.push(format!("▓░ {}", info.vault_name));
169 lines.push(format!(" codename {}", info.codename));
170 if !info.repo.is_empty() {
171 lines.push(format!(" repo {}", info.repo));
172 }
173 lines.push(format!(" created {}", info.created));
174 lines.push(format!(" recipients {}", info.recipient_count));
175
176 if info.entries.is_empty() {
177 lines.push(String::new());
178 lines.push(" no keys in vault".into());
179 return lines;
180 }
181
182 lines.push(String::new());
183
184 let key_width = info.entries.iter().map(|e| e.key.len()).max().unwrap_or(0);
185 let desc_width = info
186 .entries
187 .iter()
188 .map(|e| e.description.len())
189 .max()
190 .unwrap_or(0);
191 let example_width = info
192 .entries
193 .iter()
194 .map(|e| {
195 e.example
196 .as_ref()
197 .map_or(0, |ex| format!("(e.g. {ex})").len())
198 })
199 .max()
200 .unwrap_or(0);
201
202 let any_tags = info.entries.iter().any(|e| !e.tags.is_empty());
204 let tag_width = if any_tags {
205 info.entries
206 .iter()
207 .map(|e| {
208 if e.tags.is_empty() {
209 0
210 } else {
211 format!("[{}]", e.tags.join(", ")).len()
212 }
213 })
214 .max()
215 .unwrap_or(0)
216 } else {
217 0
218 };
219
220 for entry in &info.entries {
221 let example_str = entry
222 .example
223 .as_ref()
224 .map(|ex| format!("(e.g. {ex})"))
225 .unwrap_or_default();
226
227 let key_padded = format!("{:<key_width$}", entry.key);
228 let desc_padded = format!("{:<desc_width$}", entry.description);
229 let ex_padded = format!("{example_str:<example_width$}");
230
231 let tag_str = if entry.tags.is_empty() {
232 String::new()
233 } else {
234 format!("[{}]", entry.tags.join(", "))
235 };
236 let tag_padded = if any_tags {
237 format!(" {tag_str:<tag_width$}")
238 } else {
239 String::new()
240 };
241
242 let lifecycle =
244 lifecycle_segment(entry.rotation_interval_days, entry.expires_at.as_deref());
245 let lifecycle_str = if lifecycle.is_empty() {
246 String::new()
247 } else {
248 format!(" {lifecycle}")
249 };
250
251 let scoped_str = if has_meta && !entry.scoped_recipients.is_empty() {
253 format!(" ✦ {}", entry.scoped_recipients.join(", "))
254 } else {
255 String::new()
256 };
257
258 lines.push(format!(
259 " {key_padded} {desc_padded} {ex_padded}{tag_padded}{lifecycle_str}{scoped_str}"
260 ));
261 }
262
263 lines
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use std::collections::BTreeMap;
270
271 fn test_vault_bytes(schema: BTreeMap<String, types::SchemaEntry>) -> Vec<u8> {
272 let vault = types::Vault {
273 version: types::VAULT_VERSION.into(),
274 created: "2026-01-01T00:00:00Z".into(),
275 vault_name: ".murk".into(),
276 repo: "https://github.com/test/repo".into(),
277 recipients: vec!["age1test".into()],
278 schema,
279 policy: None,
280 secrets: BTreeMap::new(),
281 meta: String::new(),
282 };
283 serde_json::to_vec(&vault).unwrap()
284 }
285
286 #[test]
287 fn vault_info_basic() {
288 let mut schema = BTreeMap::new();
289 schema.insert(
290 "DB_URL".into(),
291 types::SchemaEntry {
292 description: "database url".into(),
293 example: Some("postgres://...".into()),
294 tags: vec!["db".into()],
295 ..Default::default()
296 },
297 );
298 let bytes = test_vault_bytes(schema);
299
300 let info = vault_info(&bytes, &[], None).unwrap();
301 assert_eq!(info.vault_name, ".murk");
302 assert!(!info.codename.is_empty());
303 assert_eq!(info.repo, "https://github.com/test/repo");
304 assert_eq!(info.recipient_count, 1);
305 assert_eq!(info.entries.len(), 1);
306 assert_eq!(info.entries[0].key, "DB_URL");
307 assert_eq!(info.entries[0].description, "database url");
308 assert_eq!(info.entries[0].example.as_deref(), Some("postgres://..."));
309 }
310
311 #[test]
312 fn vault_info_tag_filter() {
313 let mut schema = BTreeMap::new();
314 schema.insert(
315 "DB_URL".into(),
316 types::SchemaEntry {
317 description: "db".into(),
318 example: None,
319 tags: vec!["db".into()],
320 ..Default::default()
321 },
322 );
323 schema.insert(
324 "API_KEY".into(),
325 types::SchemaEntry {
326 description: "api".into(),
327 example: None,
328 tags: vec!["api".into()],
329 ..Default::default()
330 },
331 );
332 let bytes = test_vault_bytes(schema);
333
334 let info = vault_info(&bytes, &["db".into()], None).unwrap();
335 assert_eq!(info.entries.len(), 1);
336 assert_eq!(info.entries[0].key, "DB_URL");
337 }
338
339 #[test]
340 fn vault_info_empty_schema() {
341 let bytes = test_vault_bytes(BTreeMap::new());
342 let info = vault_info(&bytes, &[], None).unwrap();
343 assert!(info.entries.is_empty());
344 }
345
346 #[test]
347 fn vault_info_invalid_json() {
348 let result = vault_info(b"not json", &[], None);
349 assert!(result.is_err());
350 }
351
352 #[test]
353 fn vault_info_valid_json_missing_fields() {
354 let result = vault_info(b"{\"foo\": \"bar\"}", &[], None);
356 assert!(result.is_err());
357 }
358
359 #[test]
362 fn format_info_empty_vault() {
363 let info = VaultInfo {
364 vault_name: "test.murk".into(),
365 codename: "bright-fox-dawn".into(),
366 repo: String::new(),
367 created: "2026-01-01T00:00:00Z".into(),
368 recipient_count: 1,
369 recipient_names: vec![],
370 self_name: None,
371 self_pubkey: None,
372 entries: vec![],
373 };
374 let lines = format_info_lines(&info, false);
375 assert!(lines[0].contains("test.murk"));
376 assert!(lines[1].contains("bright-fox-dawn"));
377 assert!(lines.iter().any(|l| l.contains("no keys in vault")));
378 }
379
380 #[test]
381 fn format_info_with_entries() {
382 let info = VaultInfo {
383 vault_name: ".murk".into(),
384 codename: "cool-name".into(),
385 repo: "https://github.com/test/repo".into(),
386 created: "2026-01-01T00:00:00Z".into(),
387 recipient_count: 2,
388 recipient_names: vec![],
389 self_name: None,
390 self_pubkey: None,
391 entries: vec![
392 InfoEntry {
393 key: "DATABASE_URL".into(),
394 description: "Production DB".into(),
395 example: Some("postgres://...".into()),
396 tags: vec![],
397 scoped_recipients: vec![],
398 ..Default::default()
399 },
400 InfoEntry {
401 key: "API_KEY".into(),
402 description: "OpenAI key".into(),
403 example: None,
404 tags: vec![],
405 scoped_recipients: vec![],
406 ..Default::default()
407 },
408 ],
409 };
410 let lines = format_info_lines(&info, false);
411 assert!(lines.iter().any(|l| l.contains("repo")));
412 assert!(lines.iter().any(|l| l.contains("DATABASE_URL")));
413 assert!(lines.iter().any(|l| l.contains("API_KEY")));
414 assert!(lines.iter().any(|l| l.contains("(e.g. postgres://...)")));
415 }
416
417 #[test]
418 fn format_info_with_tags_and_scoped() {
419 let info = VaultInfo {
420 vault_name: ".murk".into(),
421 codename: "cool-name".into(),
422 repo: String::new(),
423 created: "2026-01-01T00:00:00Z".into(),
424 recipient_count: 2,
425 recipient_names: vec![],
426 self_name: None,
427 self_pubkey: None,
428 entries: vec![InfoEntry {
429 key: "DB_URL".into(),
430 description: "Database".into(),
431 example: None,
432 tags: vec!["prod".into()],
433 scoped_recipients: vec!["alice".into()],
434 ..Default::default()
435 }],
436 };
437 let lines = format_info_lines(&info, true);
438 let entry_line = lines.iter().find(|l| l.contains("DB_URL")).unwrap();
439 assert!(entry_line.contains("[prod]"));
440 assert!(entry_line.contains("✦ alice"));
441 }
442
443 #[test]
444 fn format_info_tags_visible_without_meta() {
445 let info = VaultInfo {
446 vault_name: ".murk".into(),
447 codename: "cool-name".into(),
448 repo: String::new(),
449 created: "2026-01-01T00:00:00Z".into(),
450 recipient_count: 1,
451 recipient_names: vec![],
452 self_name: None,
453 self_pubkey: None,
454 entries: vec![InfoEntry {
455 key: "DB_URL".into(),
456 description: "Database".into(),
457 example: None,
458 tags: vec!["prod".into()],
459 scoped_recipients: vec![],
460 ..Default::default()
461 }],
462 };
463 let lines = format_info_lines(&info, false);
465 let entry_line = lines.iter().find(|l| l.contains("DB_URL")).unwrap();
466 assert!(entry_line.contains("[prod]"));
467 }
468
469 #[test]
470 fn format_info_recipient_count() {
471 let info = VaultInfo {
472 vault_name: ".murk".into(),
473 codename: "cool-name".into(),
474 repo: String::new(),
475 created: "2026-01-01T00:00:00Z".into(),
476 recipient_count: 3,
477 recipient_names: vec![],
478 self_name: None,
479 self_pubkey: None,
480 entries: vec![],
481 };
482 let lines = format_info_lines(&info, false);
483 assert!(lines.iter().any(|l| l.contains('3')));
484 }
485
486 #[test]
487 fn format_info_no_repo_omitted() {
488 let info = VaultInfo {
489 vault_name: ".murk".into(),
490 codename: "cool-name".into(),
491 repo: String::new(),
492 created: "2026-01-01T00:00:00Z".into(),
493 recipient_count: 1,
494 recipient_names: vec![],
495 self_name: None,
496 self_pubkey: None,
497 entries: vec![],
498 };
499 let lines = format_info_lines(&info, false);
500 assert!(!lines.iter().any(|l| l.contains("repo")));
501 }
502
503 #[test]
504 fn format_info_with_repo() {
505 let info = VaultInfo {
506 vault_name: ".murk".into(),
507 codename: "cool-name".into(),
508 repo: "https://github.com/test/repo".into(),
509 created: "2026-01-01T00:00:00Z".into(),
510 recipient_count: 1,
511 recipient_names: vec![],
512 self_name: None,
513 self_pubkey: None,
514 entries: vec![],
515 };
516 let lines = format_info_lines(&info, false);
517 assert!(lines.iter().any(|l| l.contains("repo")));
518 }
519
520 #[test]
521 fn format_info_multiple_tags() {
522 let info = VaultInfo {
523 vault_name: ".murk".into(),
524 codename: "cool-name".into(),
525 repo: String::new(),
526 created: "2026-01-01T00:00:00Z".into(),
527 recipient_count: 1,
528 recipient_names: vec![],
529 self_name: None,
530 self_pubkey: None,
531 entries: vec![InfoEntry {
532 key: "KEY".into(),
533 description: "desc".into(),
534 example: None,
535 tags: vec!["prod".into(), "db".into()],
536 scoped_recipients: vec![],
537 ..Default::default()
538 }],
539 };
540 let lines = format_info_lines(&info, false);
541 let entry_line = lines.iter().find(|l| l.contains("KEY")).unwrap();
542 assert!(entry_line.contains("[prod, db]"));
543 }
544
545 #[test]
546 fn vault_info_preserves_timestamps() {
547 let mut schema = BTreeMap::new();
548 schema.insert(
549 "KEY".into(),
550 types::SchemaEntry {
551 description: "test".into(),
552 created: Some("2026-03-01T00:00:00Z".into()),
553 updated: Some("2026-03-15T00:00:00Z".into()),
554 ..Default::default()
555 },
556 );
557 let bytes = test_vault_bytes(schema);
558 let info = vault_info(&bytes, &[], None).unwrap();
559 assert_eq!(info.entries.len(), 1);
561 assert_eq!(info.entries[0].key, "KEY");
562 }
563
564 #[test]
567 fn lifecycle_segment_renders_both_or_neither() {
568 assert_eq!(lifecycle_segment(None, None), "");
569 assert_eq!(lifecycle_segment(Some(90), None), "rotate 90d");
570 assert_eq!(
571 lifecycle_segment(None, Some("2026-09-01T23:59:59Z")),
572 "expires 2026-09-01"
573 );
574 assert_eq!(
575 lifecycle_segment(Some(30), Some("2026-09-01T23:59:59Z")),
576 "rotate 30d expires 2026-09-01"
577 );
578 }
579
580 #[test]
581 fn vault_info_carries_lifecycle_fields() {
582 let mut schema = BTreeMap::new();
583 schema.insert(
584 "TOKEN".into(),
585 types::SchemaEntry {
586 description: "api token".into(),
587 rotation_interval_days: Some(90),
588 expires_at: Some("2026-09-01T23:59:59Z".into()),
589 ..Default::default()
590 },
591 );
592 let bytes = test_vault_bytes(schema);
593 let info = vault_info(&bytes, &[], None).unwrap();
594 assert_eq!(info.entries[0].rotation_interval_days, Some(90));
595 assert_eq!(
596 info.entries[0].expires_at.as_deref(),
597 Some("2026-09-01T23:59:59Z")
598 );
599 }
600
601 #[test]
602 fn format_info_shows_lifecycle_without_meta() {
603 let info = VaultInfo {
604 vault_name: ".murk".into(),
605 codename: "cool-name".into(),
606 repo: String::new(),
607 created: "2026-01-01T00:00:00Z".into(),
608 recipient_count: 1,
609 recipient_names: vec![],
610 self_name: None,
611 self_pubkey: None,
612 entries: vec![InfoEntry {
613 key: "TOKEN".into(),
614 description: "api token".into(),
615 rotation_interval_days: Some(90),
616 expires_at: Some("2026-09-01T23:59:59Z".into()),
617 ..Default::default()
618 }],
619 };
620 let line = format_info_lines(&info, false)
622 .into_iter()
623 .find(|l| l.contains("TOKEN"))
624 .unwrap();
625 assert!(line.contains("rotate 90d"), "got: {line}");
626 assert!(line.contains("expires 2026-09-01"), "got: {line}");
627 }
628}