1use crate::{codename, types};
4
5const PUBKEY_DISPLAY_LEN: usize = 12;
7
8#[derive(Debug)]
10pub struct InfoEntry {
11 pub key: String,
12 pub description: String,
13 pub example: Option<String>,
14 pub tags: Vec<String>,
15 pub scoped_recipients: Vec<String>,
17}
18
19#[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 recipient_names: Vec<String>,
29 pub entries: Vec<InfoEntry>,
30}
31
32pub 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 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 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 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
122pub 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 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 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 let result = vault_info(b"{\"foo\": \"bar\"}", &[], None);
305 assert!(result.is_err());
306 }
307
308 #[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 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}