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                ..Default::default()
245            },
246        );
247        let bytes = test_vault_bytes(schema);
248
249        let info = vault_info(&bytes, &[], None).unwrap();
250        assert_eq!(info.vault_name, ".murk");
251        assert!(!info.codename.is_empty());
252        assert_eq!(info.repo, "https://github.com/test/repo");
253        assert_eq!(info.recipient_count, 1);
254        assert_eq!(info.entries.len(), 1);
255        assert_eq!(info.entries[0].key, "DB_URL");
256        assert_eq!(info.entries[0].description, "database url");
257        assert_eq!(info.entries[0].example.as_deref(), Some("postgres://..."));
258    }
259
260    #[test]
261    fn vault_info_tag_filter() {
262        let mut schema = BTreeMap::new();
263        schema.insert(
264            "DB_URL".into(),
265            types::SchemaEntry {
266                description: "db".into(),
267                example: None,
268                tags: vec!["db".into()],
269                ..Default::default()
270            },
271        );
272        schema.insert(
273            "API_KEY".into(),
274            types::SchemaEntry {
275                description: "api".into(),
276                example: None,
277                tags: vec!["api".into()],
278                ..Default::default()
279            },
280        );
281        let bytes = test_vault_bytes(schema);
282
283        let info = vault_info(&bytes, &["db".into()], None).unwrap();
284        assert_eq!(info.entries.len(), 1);
285        assert_eq!(info.entries[0].key, "DB_URL");
286    }
287
288    #[test]
289    fn vault_info_empty_schema() {
290        let bytes = test_vault_bytes(BTreeMap::new());
291        let info = vault_info(&bytes, &[], None).unwrap();
292        assert!(info.entries.is_empty());
293    }
294
295    #[test]
296    fn vault_info_invalid_json() {
297        let result = vault_info(b"not json", &[], None);
298        assert!(result.is_err());
299    }
300
301    #[test]
302    fn vault_info_valid_json_missing_fields() {
303        // Valid JSON but not a vault — should fail deserialization.
304        let result = vault_info(b"{\"foo\": \"bar\"}", &[], None);
305        assert!(result.is_err());
306    }
307
308    // ── format_info_lines tests ──
309
310    #[test]
311    fn format_info_empty_vault() {
312        let info = VaultInfo {
313            vault_name: "test.murk".into(),
314            codename: "bright-fox-dawn".into(),
315            repo: String::new(),
316            created: "2026-01-01T00:00:00Z".into(),
317            recipient_count: 1,
318            recipient_names: vec![],
319            entries: vec![],
320        };
321        let lines = format_info_lines(&info, false);
322        assert!(lines[0].contains("test.murk"));
323        assert!(lines[1].contains("bright-fox-dawn"));
324        assert!(lines.iter().any(|l| l.contains("no keys in vault")));
325    }
326
327    #[test]
328    fn format_info_with_entries() {
329        let info = VaultInfo {
330            vault_name: ".murk".into(),
331            codename: "cool-name".into(),
332            repo: "https://github.com/test/repo".into(),
333            created: "2026-01-01T00:00:00Z".into(),
334            recipient_count: 2,
335            recipient_names: vec![],
336            entries: vec![
337                InfoEntry {
338                    key: "DATABASE_URL".into(),
339                    description: "Production DB".into(),
340                    example: Some("postgres://...".into()),
341                    tags: vec![],
342                    scoped_recipients: vec![],
343                },
344                InfoEntry {
345                    key: "API_KEY".into(),
346                    description: "OpenAI key".into(),
347                    example: None,
348                    tags: vec![],
349                    scoped_recipients: vec![],
350                },
351            ],
352        };
353        let lines = format_info_lines(&info, false);
354        assert!(lines.iter().any(|l| l.contains("repo")));
355        assert!(lines.iter().any(|l| l.contains("DATABASE_URL")));
356        assert!(lines.iter().any(|l| l.contains("API_KEY")));
357        assert!(lines.iter().any(|l| l.contains("(e.g. postgres://...)")));
358    }
359
360    #[test]
361    fn format_info_with_tags_and_scoped() {
362        let info = VaultInfo {
363            vault_name: ".murk".into(),
364            codename: "cool-name".into(),
365            repo: String::new(),
366            created: "2026-01-01T00:00:00Z".into(),
367            recipient_count: 2,
368            recipient_names: vec![],
369            entries: vec![InfoEntry {
370                key: "DB_URL".into(),
371                description: "Database".into(),
372                example: None,
373                tags: vec!["prod".into()],
374                scoped_recipients: vec!["alice".into()],
375            }],
376        };
377        let lines = format_info_lines(&info, true);
378        let entry_line = lines.iter().find(|l| l.contains("DB_URL")).unwrap();
379        assert!(entry_line.contains("[prod]"));
380        assert!(entry_line.contains("✦ alice"));
381    }
382
383    #[test]
384    fn format_info_tags_visible_without_meta() {
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: 1,
391            recipient_names: vec![],
392            entries: vec![InfoEntry {
393                key: "DB_URL".into(),
394                description: "Database".into(),
395                example: None,
396                tags: vec!["prod".into()],
397                scoped_recipients: vec![],
398            }],
399        };
400        // has_meta=false — tags should still show.
401        let lines = format_info_lines(&info, false);
402        let entry_line = lines.iter().find(|l| l.contains("DB_URL")).unwrap();
403        assert!(entry_line.contains("[prod]"));
404    }
405}