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    pub entries: Vec<InfoEntry>,
30}
31
32/// Compute vault info from raw vault bytes.
33///
34/// `raw_bytes` is the full file contents (for codename computation).
35/// `tags` filters entries by tag (empty = all).
36/// `secret_key` enables meta decryption for scoped-recipient display names.
37pub fn vault_info(
38    raw_bytes: &[u8],
39    tags: &[String],
40    secret_key: Option<&str>,
41) -> Result<VaultInfo, String> {
42    let vault: types::Vault = serde_json::from_slice(raw_bytes).map_err(|e| e.to_string())?;
43
44    let codename = codename::from_bytes(raw_bytes);
45
46    // Filter by tag if specified.
47    let filtered: Vec<(&String, &types::SchemaEntry)> = if tags.is_empty() {
48        vault.schema.iter().collect()
49    } else {
50        vault
51            .schema
52            .iter()
53            .filter(|(_, e)| e.tags.iter().any(|t| tags.contains(t)))
54            .collect()
55    };
56
57    // Try to decrypt meta for recipient names.
58    let meta_data = secret_key.and_then(|sk| {
59        let identity = crate::crypto::parse_identity(sk).ok()?;
60        crate::decrypt_meta(&vault, &identity)
61    });
62
63    let entries = filtered
64        .iter()
65        .map(|(key, entry)| {
66            let scoped_recipients = if let Some(ref meta) = meta_data {
67                vault
68                    .secrets
69                    .get(key.as_str())
70                    .map(|s| {
71                        s.scoped
72                            .keys()
73                            .map(|pk| {
74                                meta.recipients.get(pk).cloned().unwrap_or_else(|| {
75                                    pk.chars().take(PUBKEY_DISPLAY_LEN).collect::<String>()
76                                        + "\u{2026}"
77                                })
78                            })
79                            .collect()
80                    })
81                    .unwrap_or_default()
82            } else {
83                vec![]
84            };
85
86            InfoEntry {
87                key: (*key).clone(),
88                description: entry.description.clone(),
89                example: entry.example.clone(),
90                tags: entry.tags.clone(),
91                scoped_recipients,
92            }
93        })
94        .collect();
95
96    // Build recipient name list when meta is available.
97    let recipient_names = if let Some(ref meta) = meta_data {
98        vault
99            .recipients
100            .iter()
101            .map(|pk| {
102                meta.recipients.get(pk).cloned().unwrap_or_else(|| {
103                    pk.chars().take(PUBKEY_DISPLAY_LEN).collect::<String>() + "\u{2026}"
104                })
105            })
106            .collect()
107    } else {
108        vec![]
109    };
110
111    Ok(VaultInfo {
112        vault_name: vault.vault_name.clone(),
113        codename,
114        repo: vault.repo.clone(),
115        created: vault.created.clone(),
116        recipient_count: vault.recipients.len(),
117        recipient_names,
118        entries,
119    })
120}
121
122/// Format vault info as plain-text lines (no ANSI colors).
123/// `has_meta` indicates whether scoped/tag columns should be shown.
124pub fn format_info_lines(info: &VaultInfo, has_meta: bool) -> Vec<String> {
125    let mut lines = Vec::new();
126
127    lines.push(format!("▓░ {}", info.vault_name));
128    lines.push(format!("   codename    {}", info.codename));
129    if !info.repo.is_empty() {
130        lines.push(format!("   repo        {}", info.repo));
131    }
132    lines.push(format!("   created     {}", info.created));
133    lines.push(format!("   recipients  {}", info.recipient_count));
134
135    if info.entries.is_empty() {
136        lines.push(String::new());
137        lines.push("   no keys in vault".into());
138        return lines;
139    }
140
141    lines.push(String::new());
142
143    let key_width = info.entries.iter().map(|e| e.key.len()).max().unwrap_or(0);
144    let desc_width = info
145        .entries
146        .iter()
147        .map(|e| e.description.len())
148        .max()
149        .unwrap_or(0);
150    let example_width = info
151        .entries
152        .iter()
153        .map(|e| {
154            e.example
155                .as_ref()
156                .map_or(0, |ex| format!("(e.g. {ex})").len())
157        })
158        .max()
159        .unwrap_or(0);
160
161    // Tags are always public — show them regardless of key availability.
162    let any_tags = info.entries.iter().any(|e| !e.tags.is_empty());
163    let tag_width = if any_tags {
164        info.entries
165            .iter()
166            .map(|e| {
167                if e.tags.is_empty() {
168                    0
169                } else {
170                    format!("[{}]", e.tags.join(", ")).len()
171                }
172            })
173            .max()
174            .unwrap_or(0)
175    } else {
176        0
177    };
178
179    for entry in &info.entries {
180        let example_str = entry
181            .example
182            .as_ref()
183            .map(|ex| format!("(e.g. {ex})"))
184            .unwrap_or_default();
185
186        let key_padded = format!("{:<key_width$}", entry.key);
187        let desc_padded = format!("{:<desc_width$}", entry.description);
188        let ex_padded = format!("{example_str:<example_width$}");
189
190        let tag_str = if entry.tags.is_empty() {
191            String::new()
192        } else {
193            format!("[{}]", entry.tags.join(", "))
194        };
195        let tag_padded = if any_tags {
196            format!("  {tag_str:<tag_width$}")
197        } else {
198            String::new()
199        };
200
201        // Scoped recipients only shown when meta is available.
202        let scoped_str = if has_meta && !entry.scoped_recipients.is_empty() {
203            format!("  ✦ {}", entry.scoped_recipients.join(", "))
204        } else {
205            String::new()
206        };
207
208        lines.push(format!(
209            "   {key_padded}  {desc_padded}  {ex_padded}{tag_padded}{scoped_str}"
210        ));
211    }
212
213    lines
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::collections::BTreeMap;
220
221    fn test_vault_bytes(schema: BTreeMap<String, types::SchemaEntry>) -> Vec<u8> {
222        let vault = types::Vault {
223            version: types::VAULT_VERSION.into(),
224            created: "2026-01-01T00:00:00Z".into(),
225            vault_name: ".murk".into(),
226            repo: "https://github.com/test/repo".into(),
227            recipients: vec!["age1test".into()],
228            schema,
229            secrets: BTreeMap::new(),
230            meta: String::new(),
231        };
232        serde_json::to_vec(&vault).unwrap()
233    }
234
235    #[test]
236    fn vault_info_basic() {
237        let mut schema = BTreeMap::new();
238        schema.insert(
239            "DB_URL".into(),
240            types::SchemaEntry {
241                description: "database url".into(),
242                example: Some("postgres://...".into()),
243                tags: vec!["db".into()],
244            },
245        );
246        let bytes = test_vault_bytes(schema);
247
248        let info = vault_info(&bytes, &[], None).unwrap();
249        assert_eq!(info.vault_name, ".murk");
250        assert!(!info.codename.is_empty());
251        assert_eq!(info.repo, "https://github.com/test/repo");
252        assert_eq!(info.recipient_count, 1);
253        assert_eq!(info.entries.len(), 1);
254        assert_eq!(info.entries[0].key, "DB_URL");
255        assert_eq!(info.entries[0].description, "database url");
256        assert_eq!(info.entries[0].example.as_deref(), Some("postgres://..."));
257    }
258
259    #[test]
260    fn vault_info_tag_filter() {
261        let mut schema = BTreeMap::new();
262        schema.insert(
263            "DB_URL".into(),
264            types::SchemaEntry {
265                description: "db".into(),
266                example: None,
267                tags: vec!["db".into()],
268            },
269        );
270        schema.insert(
271            "API_KEY".into(),
272            types::SchemaEntry {
273                description: "api".into(),
274                example: None,
275                tags: vec!["api".into()],
276            },
277        );
278        let bytes = test_vault_bytes(schema);
279
280        let info = vault_info(&bytes, &["db".into()], None).unwrap();
281        assert_eq!(info.entries.len(), 1);
282        assert_eq!(info.entries[0].key, "DB_URL");
283    }
284
285    #[test]
286    fn vault_info_empty_schema() {
287        let bytes = test_vault_bytes(BTreeMap::new());
288        let info = vault_info(&bytes, &[], None).unwrap();
289        assert!(info.entries.is_empty());
290    }
291
292    #[test]
293    fn vault_info_invalid_json() {
294        let result = vault_info(b"not json", &[], None);
295        assert!(result.is_err());
296    }
297
298    #[test]
299    fn vault_info_valid_json_missing_fields() {
300        // Valid JSON but not a vault — should fail deserialization.
301        let result = vault_info(b"{\"foo\": \"bar\"}", &[], None);
302        assert!(result.is_err());
303    }
304
305    // ── format_info_lines tests ──
306
307    #[test]
308    fn format_info_empty_vault() {
309        let info = VaultInfo {
310            vault_name: "test.murk".into(),
311            codename: "bright-fox-dawn".into(),
312            repo: String::new(),
313            created: "2026-01-01T00:00:00Z".into(),
314            recipient_count: 1,
315            recipient_names: vec![],
316            entries: vec![],
317        };
318        let lines = format_info_lines(&info, false);
319        assert!(lines[0].contains("test.murk"));
320        assert!(lines[1].contains("bright-fox-dawn"));
321        assert!(lines.iter().any(|l| l.contains("no keys in vault")));
322    }
323
324    #[test]
325    fn format_info_with_entries() {
326        let info = VaultInfo {
327            vault_name: ".murk".into(),
328            codename: "cool-name".into(),
329            repo: "https://github.com/test/repo".into(),
330            created: "2026-01-01T00:00:00Z".into(),
331            recipient_count: 2,
332            recipient_names: vec![],
333            entries: vec![
334                InfoEntry {
335                    key: "DATABASE_URL".into(),
336                    description: "Production DB".into(),
337                    example: Some("postgres://...".into()),
338                    tags: vec![],
339                    scoped_recipients: vec![],
340                },
341                InfoEntry {
342                    key: "API_KEY".into(),
343                    description: "OpenAI key".into(),
344                    example: None,
345                    tags: vec![],
346                    scoped_recipients: vec![],
347                },
348            ],
349        };
350        let lines = format_info_lines(&info, false);
351        assert!(lines.iter().any(|l| l.contains("repo")));
352        assert!(lines.iter().any(|l| l.contains("DATABASE_URL")));
353        assert!(lines.iter().any(|l| l.contains("API_KEY")));
354        assert!(lines.iter().any(|l| l.contains("(e.g. postgres://...)")));
355    }
356
357    #[test]
358    fn format_info_with_tags_and_scoped() {
359        let info = VaultInfo {
360            vault_name: ".murk".into(),
361            codename: "cool-name".into(),
362            repo: String::new(),
363            created: "2026-01-01T00:00:00Z".into(),
364            recipient_count: 2,
365            recipient_names: vec![],
366            entries: vec![InfoEntry {
367                key: "DB_URL".into(),
368                description: "Database".into(),
369                example: None,
370                tags: vec!["prod".into()],
371                scoped_recipients: vec!["alice".into()],
372            }],
373        };
374        let lines = format_info_lines(&info, true);
375        let entry_line = lines.iter().find(|l| l.contains("DB_URL")).unwrap();
376        assert!(entry_line.contains("[prod]"));
377        assert!(entry_line.contains("✦ alice"));
378    }
379
380    #[test]
381    fn format_info_tags_visible_without_meta() {
382        let info = VaultInfo {
383            vault_name: ".murk".into(),
384            codename: "cool-name".into(),
385            repo: String::new(),
386            created: "2026-01-01T00:00:00Z".into(),
387            recipient_count: 1,
388            recipient_names: vec![],
389            entries: vec![InfoEntry {
390                key: "DB_URL".into(),
391                description: "Database".into(),
392                example: None,
393                tags: vec!["prod".into()],
394                scoped_recipients: vec![],
395            }],
396        };
397        // has_meta=false — tags should still show.
398        let lines = format_info_lines(&info, false);
399        let entry_line = lines.iter().find(|l| l.contains("DB_URL")).unwrap();
400        assert!(entry_line.contains("[prod]"));
401    }
402}