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 },
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 let result = vault_info(b"{\"foo\": \"bar\"}", &[], None);
302 assert!(result.is_err());
303 }
304
305 #[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 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}