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)]
10pub struct InfoEntry {
11    pub key: String,
12    pub description: String,
13    pub example: Option<String>,
14    pub tags: Vec<String>,
15    /// Display names (or truncated pubkeys) of recipients with scoped overrides.
16    pub scoped_recipients: Vec<String>,
17}
18
19/// Aggregated vault information for display.
20#[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    /// Recipient display names (populated when key is available).
28    pub recipient_names: Vec<String>,
29    /// Your own identity in this vault (display name, if known).
30    pub self_name: Option<String>,
31    /// Your own pubkey in this vault (for reference even without meta).
32    pub self_pubkey: Option<String>,
33    pub entries: Vec<InfoEntry>,
34}
35
36/// Compute vault info from raw vault bytes.
37///
38/// `raw_bytes` is the full file contents (for codename computation).
39/// `tags` filters entries by tag (empty = all).
40/// `secret_key` enables meta decryption for scoped-recipient display names.
41pub 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    // Filter by tag if specified.
51    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    // Derive self pubkey from the secret key (if available).
62    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    // Try to decrypt meta for recipient names.
68    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    // Build recipient name list when meta is available.
107    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    // Resolve self name from meta if pubkey is known.
122    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
141/// Format vault info as plain-text lines (no ANSI colors).
142/// `has_meta` indicates whether scoped/tag columns should be shown.
143pub 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    // Tags are always public — show them regardless of key availability.
181    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        // Scoped recipients only shown when meta is available.
221        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        // Valid JSON but not a vault — should fail deserialization.
323        let result = vault_info(b"{\"foo\": \"bar\"}", &[], None);
324        assert!(result.is_err());
325    }
326
327    // ── format_info_lines tests ──
328
329    #[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        // has_meta=false — tags should still show.
428        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        // Timestamps are in schema, not in InfoEntry — but the vault parses correctly.
523        assert_eq!(info.entries.len(), 1);
524        assert_eq!(info.entries[0].key, "KEY");
525    }
526}