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