Skip to main content

murk_cli/
info.rs

1//! Vault info/introspection logic.
2
3use crate::{codename, types};
4
5/// Number of pubkey characters to show when a display name is unavailable.
6const PUBKEY_DISPLAY_LEN: usize = 12;
7
8/// A single key entry in the vault info output.
9#[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    /// Soft rotation interval in days, if set (public schema metadata).
16    pub rotation_interval_days: Option<u32>,
17    /// Hard expiry (ISO-8601 UTC), if set (public schema metadata).
18    pub expires_at: Option<String>,
19    /// Display names (or truncated pubkeys) of recipients with scoped overrides.
20    pub scoped_recipients: Vec<String>,
21}
22
23/// Build the at-a-glance lifecycle segment shown after each info row, e.g.
24/// `rotate 90d  expires 2026-09-01`. Returns an empty string when neither is
25/// set. The expiry is shown as a bare date (the stored time is end-of-day).
26/// Public schema, so this renders without a key — same as tags.
27pub 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/// Aggregated vault information for display.
40#[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    /// Recipient display names (populated when key is available).
48    pub recipient_names: Vec<String>,
49    /// Your own identity in this vault (display name, if known).
50    pub self_name: Option<String>,
51    /// Your own pubkey in this vault (for reference even without meta).
52    pub self_pubkey: Option<String>,
53    pub entries: Vec<InfoEntry>,
54}
55
56/// Compute vault info from raw vault bytes.
57///
58/// `raw_bytes` is the full file contents (for codename computation).
59/// `tags` filters entries by tag (empty = all).
60/// `secret_key` enables meta decryption for scoped-recipient display names.
61pub 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    // Filter by tag if specified.
71    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    // Derive self pubkey from the secret key (if available).
82    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    // Try to decrypt meta for recipient names.
88    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    // Build recipient name list when meta is available.
129    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    // Resolve self name from meta if pubkey is known.
144    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
163/// Format vault info as plain-text lines (no ANSI colors).
164/// `has_meta` indicates whether scoped/tag columns should be shown.
165pub 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    // Tags are always public — show them regardless of key availability.
203    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        // Lifecycle policy is public — show it regardless of key, like tags.
243        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        // Scoped recipients only shown when meta is available.
252        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        // Valid JSON but not a vault — should fail deserialization.
355        let result = vault_info(b"{\"foo\": \"bar\"}", &[], None);
356        assert!(result.is_err());
357    }
358
359    // ── format_info_lines tests ──
360
361    #[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        // has_meta=false — tags should still show.
464        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        // Timestamps are in schema, not in InfoEntry — but the vault parses correctly.
560        assert_eq!(info.entries.len(), 1);
561        assert_eq!(info.entries[0].key, "KEY");
562    }
563
564    // ── lifecycle metadata ──
565
566    #[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        // has_meta=false — lifecycle is public, must still render.
621        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}