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 entries: Vec<InfoEntry>,
28}
29
30pub 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 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 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
104pub 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 let result = vault_info(b"{\"foo\": \"bar\"}", &[], None);
281 assert!(result.is_err());
282 }
283
284 #[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}