1use std::fmt::Write;
10
11use serde::Serialize;
12
13use crate::types::Vault;
14
15#[derive(Debug, Clone, Serialize)]
17pub struct AgentPlan {
18 pub entries: Vec<AgentPlanKey>,
19}
20
21#[derive(Debug, Clone, Serialize)]
24pub struct AgentPlanKey {
25 pub key: String,
26 pub description: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub example: Option<String>,
29 #[serde(skip_serializing_if = "Vec::is_empty")]
30 pub tags: Vec<String>,
31}
32
33pub fn agent_plan(vault: &Vault, tags: &[String]) -> AgentPlan {
36 let entries = vault
37 .schema
38 .iter()
39 .filter(|(_, e)| tags.is_empty() || e.tags.iter().any(|t| tags.contains(t)))
40 .map(|(name, entry)| AgentPlanKey {
41 key: name.clone(),
42 description: entry.description.clone(),
43 example: entry.example.clone(),
44 tags: entry.tags.clone(),
45 })
46 .collect();
47
48 AgentPlan { entries }
49}
50
51pub fn format_agent_plan_text(plan: &AgentPlan) -> String {
53 let mut out = format!(
54 "plan: {} key{}\n",
55 plan.entries.len(),
56 if plan.entries.len() == 1 { "" } else { "s" }
57 );
58
59 if plan.entries.is_empty() {
60 return out;
61 }
62
63 let key_width = plan.entries.iter().map(|e| e.key.len()).max().unwrap_or(0);
64 let desc_width = plan
65 .entries
66 .iter()
67 .map(|e| e.description.len())
68 .max()
69 .unwrap_or(0);
70 let example_width = plan
71 .entries
72 .iter()
73 .map(|e| {
74 e.example
75 .as_ref()
76 .map_or(0, |ex| format!("(e.g. {ex})").len())
77 })
78 .max()
79 .unwrap_or(0);
80 let any_tags = plan.entries.iter().any(|e| !e.tags.is_empty());
81
82 out.push('\n');
83 for entry in &plan.entries {
84 let example_str = entry
85 .example
86 .as_ref()
87 .map(|ex| format!("(e.g. {ex})"))
88 .unwrap_or_default();
89
90 let key_padded = format!("{:<key_width$}", entry.key);
91 let desc_padded = format!("{:<desc_width$}", entry.description);
92 let ex_padded = format!("{example_str:<example_width$}");
93
94 let tag_str = if entry.tags.is_empty() {
95 String::new()
96 } else {
97 format!(" [{}]", entry.tags.join(", "))
98 };
99
100 if any_tags {
101 let _ = writeln!(out, " {key_padded} {desc_padded} {ex_padded}{tag_str}");
102 } else {
103 let _ = writeln!(out, " {key_padded} {desc_padded} {ex_padded}");
104 }
105 }
106
107 out
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use crate::types::{SchemaEntry, VAULT_VERSION};
114 use std::collections::BTreeMap;
115
116 fn make_vault() -> Vault {
117 let mut schema = BTreeMap::new();
118 schema.insert(
119 "DATABASE_URL".into(),
120 SchemaEntry {
121 description: "Postgres connection string".into(),
122 example: Some("postgres://localhost/db".into()),
123 tags: vec!["db".into()],
124 created: None,
125 updated: None,
126 ..Default::default()
127 },
128 );
129 schema.insert(
130 "STRIPE_SECRET_KEY".into(),
131 SchemaEntry {
132 description: "Stripe API key".into(),
133 example: None,
134 tags: vec!["payments".into()],
135 created: None,
136 updated: None,
137 ..Default::default()
138 },
139 );
140 Vault {
141 version: VAULT_VERSION.into(),
142 created: "2026-01-01T00:00:00Z".into(),
143 vault_name: "myapp".into(),
144 repo: String::new(),
145 recipients: vec!["age1exampleabc".into()],
146 schema,
147 policy: None,
148 secrets: BTreeMap::new(),
149 meta: "encrypted-meta-blob".into(),
150 }
151 }
152
153 #[test]
154 fn plan_includes_all_keys_when_no_tag_filter() {
155 let vault = make_vault();
156 let plan = agent_plan(&vault, &[]);
157 assert_eq!(plan.entries.len(), 2);
158 }
159
160 #[test]
161 fn plan_filters_by_tag() {
162 let vault = make_vault();
163 let plan = agent_plan(&vault, &["db".to_string()]);
164 assert_eq!(plan.entries.len(), 1);
165 assert_eq!(plan.entries[0].key, "DATABASE_URL");
166 }
167
168 #[test]
169 fn plan_empty_when_filter_matches_nothing() {
170 let vault = make_vault();
171 let plan = agent_plan(&vault, &["nonexistent".to_string()]);
172 assert!(plan.entries.is_empty());
173 }
174
175 #[test]
176 fn json_does_not_leak_recipients_meta_or_vault_name() {
177 let vault = make_vault();
178 let plan = agent_plan(&vault, &[]);
179 let json = serde_json::to_string(&plan).unwrap();
180 assert!(!json.contains("age1exampleabc"));
181 assert!(!json.contains("encrypted-meta-blob"));
182 assert!(!json.contains("recipient"));
183 assert!(!json.contains("\"meta\""));
184 assert!(!json.contains("myapp"));
185 assert!(!json.contains("vault_name"));
186 }
187
188 #[test]
189 fn text_format_includes_key_description_example_and_tag() {
190 let vault = make_vault();
191 let plan = agent_plan(&vault, &[]);
192 let text = format_agent_plan_text(&plan);
193 assert!(text.contains("plan: 2 keys"));
194 assert!(!text.contains("myapp"));
195 assert!(text.contains("DATABASE_URL"));
196 assert!(text.contains("Postgres connection string"));
197 assert!(text.contains("(e.g. postgres://localhost/db)"));
198 assert!(text.contains("[db]"));
199 }
200
201 #[test]
202 fn text_format_handles_empty_schema() {
203 let mut vault = make_vault();
204 vault.schema = BTreeMap::new();
205 let plan = agent_plan(&vault, &[]);
206 let text = format_agent_plan_text(&plan);
207 assert!(text.contains("plan: 0 keys"));
208 }
209
210 #[test]
211 fn text_format_singularizes_one_key() {
212 let mut vault = make_vault();
213 vault.schema.remove("STRIPE_SECRET_KEY");
214 let plan = agent_plan(&vault, &[]);
215 let text = format_agent_plan_text(&plan);
216 assert!(text.contains("plan: 1 key"));
217 assert!(!text.contains("1 keys"));
218 }
219}