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