1use std::collections::HashMap;
13use std::path::PathBuf;
14
15use ratatui::layout::Constraint;
16use ratatui::style::{Color, Modifier, Style};
17use ratatui::widgets::{Block, Cell, Row, Table};
18use vta_sdk::did_templates::{BUILTIN_NAMES, DidTemplate, load_embedded};
19use vta_sdk::prelude::*;
20
21use crate::duration::format_local_time;
22use crate::render::{
23 CYAN, DIM, GREEN, RED, RESET, YELLOW, is_full_display, print_full_entry, print_full_list_title,
24 print_widget,
25};
26
27pub fn cmd_validate(path: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
32 match DidTemplate::load_file(&path) {
33 Ok(tpl) => {
34 println!(
35 "{GREEN}\u{2713}{RESET} Template {CYAN}'{}'{RESET} ({DIM}{}{RESET}) is valid.",
36 tpl.name, tpl.kind
37 );
38 println!(" schemaVersion: {}", tpl.schema_version);
39 if let Some(desc) = &tpl.description {
40 println!(" description: {desc}");
41 }
42 if !tpl.methods.is_empty() {
43 println!(" methods: {}", tpl.methods.join(", "));
44 }
45 if !tpl.required_vars.is_empty() {
46 println!(" requiredVars: {}", tpl.required_vars.join(", "));
47 }
48 if !tpl.optional_vars.is_empty() {
49 let names: Vec<&str> = tpl.optional_vars.keys().map(String::as_str).collect();
50 println!(" optionalVars: {}", names.join(", "));
51 }
52 Ok(())
53 }
54 Err(e) => {
55 eprintln!("{RED}\u{2717}{RESET} Template validation failed:");
56 eprintln!(" {e}");
57 Err(format!("invalid template at {}", path.display()).into())
58 }
59 }
60}
61
62pub fn cmd_init(kind: String) -> Result<(), Box<dyn std::error::Error>> {
69 let builtin_name = match kind.as_str() {
72 "mediator" => "didcomm-mediator",
73 "agent" => "ai-agent",
74 "did-hosting" | "hosting" | "daemon" => "did-hosting-daemon",
75 "control" => "did-hosting-control",
76 "witness" | "watcher" | "server" => "did-hosting-server",
77 "webvh-hosting" => "did-hosting-daemon",
79 "webvh-control" => "did-hosting-control",
80 "webvh-daemon" => "did-hosting-daemon",
81 "webvh-server" => "did-hosting-server",
82 other if BUILTIN_NAMES.contains(&other) => other,
83 other => {
84 eprintln!(
85 "{RED}\u{2717}{RESET} Unknown builtin kind '{other}'. Available: {}",
86 BUILTIN_NAMES.join(", ")
87 );
88 return Err("unknown builtin".into());
89 }
90 };
91
92 let tpl = load_embedded(builtin_name)?;
94 let pretty = serde_json::to_string_pretty(&tpl)?;
95 println!("{pretty}");
96
97 eprintln!();
99 eprintln!(
100 "{YELLOW}Tip:{RESET} redirect to a file and edit the {DIM}name{RESET}, {DIM}description{RESET},"
101 );
102 eprintln!(" and any placeholder values before uploading. For example:");
103 eprintln!(" pnm did-templates init {kind} > my-{builtin_name}.json");
104 Ok(())
105}
106
107fn scope_label(context: Option<&str>) -> String {
110 context
111 .map(|c| format!("context '{c}'"))
112 .unwrap_or_else(|| "global".into())
113}
114
115pub async fn cmd_list(
123 client: &VtaClient,
124 context: Option<&str>,
125) -> Result<(), Box<dyn std::error::Error>> {
126 let records = match context {
127 Some(ctx) => client.list_context_did_templates(ctx).await?,
128 None => client.list_did_templates().await?,
129 };
130
131 if crate::render::is_json_output() {
132 crate::render::print_json(&records)?;
133 return Ok(());
134 }
135
136 if records.is_empty() {
137 match context {
138 Some(ctx) => println!("No DID templates stored in context '{ctx}'."),
139 None => println!("No DID templates stored on the VTA."),
140 }
141 println!(" {DIM}Scaffold one with{RESET} `pnm did-templates init <kind> > tpl.json`,");
142 let create_hint = match context {
143 Some(ctx) => format!("pnm did-templates create --context {ctx} --file tpl.json"),
144 None => "pnm did-templates create --file tpl.json".into(),
145 };
146 println!(" {DIM}then{RESET} `{create_hint}`.");
147 return Ok(());
148 }
149
150 if is_full_display() {
151 let title = match context {
152 Some(ctx) => format!("DID templates in context '{ctx}'"),
153 None => "Stored DID templates (global)".to_string(),
154 };
155 print_full_list_title(&title, records.len());
156 for r in &records {
157 let required = if r.template.required_vars.is_empty() {
158 "—".to_string()
159 } else {
160 r.template.required_vars.join(", ")
161 };
162 let description = r
163 .template
164 .description
165 .clone()
166 .unwrap_or_else(|| "—".to_string());
167 let created = format_local_time(r.created_at);
168 print_full_entry(&[
169 ("Name", &r.template.name),
170 ("Kind", &r.template.kind),
171 ("Description", &description),
172 ("Required vars", &required),
173 ("Created", &created),
174 ("Created by", &r.created_by),
175 ]);
176 }
177 return Ok(());
178 }
179
180 let dim = Style::default().fg(Color::DarkGray);
181 let header_style = Style::default()
182 .fg(Color::White)
183 .add_modifier(Modifier::BOLD);
184 let header = Row::new(vec!["Name", "Kind", "Required vars", "Created"])
185 .style(header_style)
186 .bottom_margin(1);
187
188 let rows: Vec<Row> = records
189 .iter()
190 .map(|r| {
191 let required = if r.template.required_vars.is_empty() {
192 "\u{2014}".to_string()
193 } else {
194 r.template.required_vars.join(", ")
195 };
196 let created = format_local_time(r.created_at);
197 Row::new(vec![
198 Cell::from(r.template.name.clone()).style(Style::default().fg(Color::Cyan)),
199 Cell::from(r.template.kind.clone()),
200 Cell::from(required).style(dim),
201 Cell::from(created).style(dim),
202 ])
203 })
204 .collect();
205
206 let title = match context {
207 Some(ctx) => format!(" DID templates in context '{ctx}' ({}) ", records.len()),
208 None => format!(" Stored DID templates (global) ({}) ", records.len()),
209 };
210 let table = Table::new(
211 rows,
212 [
213 Constraint::Min(24), Constraint::Length(16), Constraint::Min(24), Constraint::Length(26), ],
218 )
219 .header(header)
220 .column_spacing(2)
221 .block(Block::bordered().title(title).border_style(dim));
222
223 let height = records.len() as u16 + 4;
224 print_widget(table, height);
225 Ok(())
226}
227
228pub async fn cmd_show(
231 client: &VtaClient,
232 name: &str,
233 context: Option<&str>,
234 rendered: bool,
235 vars: Vec<(String, String)>,
236) -> Result<(), Box<dyn std::error::Error>> {
237 if rendered {
238 let mut vars_map: HashMap<String, serde_json::Value> = HashMap::new();
239 for (k, v) in vars {
240 vars_map.insert(k, serde_json::Value::String(v));
241 }
242 let doc = match context {
246 Some(ctx) => {
247 client
248 .render_context_did_template(ctx, name, vars_map)
249 .await?
250 }
251 None => client.render_did_template(name, vars_map).await?,
252 };
253 println!("{}", serde_json::to_string_pretty(&doc)?);
254 return Ok(());
255 }
256
257 let r = match context {
258 Some(ctx) => client.get_context_did_template(ctx, name).await?,
259 None => client.get_did_template(name).await?,
260 };
261 let pretty = serde_json::to_string_pretty(&r)?;
262 println!("{pretty}");
263 Ok(())
264}
265
266pub async fn cmd_create(
271 client: &VtaClient,
272 context: Option<&str>,
273 file: PathBuf,
274) -> Result<(), Box<dyn std::error::Error>> {
275 let tpl = DidTemplate::load_file(&file)
276 .map_err(|e| format!("template at {} is invalid: {e}", file.display()))?;
277 let record = match context {
278 Some(ctx) => client.create_context_did_template(ctx, tpl).await?,
279 None => client.create_did_template(tpl).await?,
280 };
281 println!(
282 "{GREEN}\u{2713}{RESET} Created {CYAN}'{}'{RESET} ({DIM}{}{RESET}) in {}.",
283 record.template.name,
284 record.template.kind,
285 scope_label(context)
286 );
287 Ok(())
288}
289
290pub async fn cmd_update(
292 client: &VtaClient,
293 name: &str,
294 context: Option<&str>,
295 file: PathBuf,
296) -> Result<(), Box<dyn std::error::Error>> {
297 let tpl = DidTemplate::load_file(&file)
298 .map_err(|e| format!("template at {} is invalid: {e}", file.display()))?;
299 if tpl.name != name {
300 return Err(format!(
301 "file's template name '{}' does not match --name argument '{}'",
302 tpl.name, name
303 )
304 .into());
305 }
306 let record = match context {
307 Some(ctx) => client.update_context_did_template(ctx, name, tpl).await?,
308 None => client.update_did_template(name, tpl).await?,
309 };
310 println!(
311 "{GREEN}\u{2713}{RESET} Updated {CYAN}'{}'{RESET} in {}.",
312 record.template.name,
313 scope_label(context)
314 );
315 Ok(())
316}
317
318pub async fn cmd_export(
326 client: &VtaClient,
327 name: &str,
328 context: Option<&str>,
329) -> Result<(), Box<dyn std::error::Error>> {
330 let record = match context {
331 Some(ctx) => client.get_context_did_template(ctx, name).await?,
332 None => client.get_did_template(name).await?,
333 };
334 let pretty = serde_json::to_string_pretty(&record.template)?;
335 println!("{pretty}");
336 Ok(())
337}
338
339pub async fn cmd_diff(
346 client: &VtaClient,
347 name: &str,
348 context: Option<&str>,
349 file: PathBuf,
350) -> Result<(), Box<dyn std::error::Error>> {
351 let local = DidTemplate::load_file(&file)
354 .map_err(|e| format!("local template at {} is invalid: {e}", file.display()))?;
355
356 let remote_record = match context {
357 Some(ctx) => client.get_context_did_template(ctx, name).await?,
358 None => client.get_did_template(name).await?,
359 };
360 let remote = remote_record.template;
361
362 let remote_val = serde_json::to_value(&remote)?;
363 let local_val = serde_json::to_value(&local)?;
364
365 let mut differences = Vec::new();
366 walk_json_diff("", &remote_val, &local_val, &mut differences);
367
368 if differences.is_empty() {
369 println!(
370 "{GREEN}\u{2713}{RESET} Local {CYAN}'{name}'{RESET} matches stored {}.",
371 scope_label(context)
372 );
373 return Ok(());
374 }
375
376 println!(
377 "{YELLOW}Differences{RESET} between stored {CYAN}'{name}'{RESET} ({}) and {}:",
378 scope_label(context),
379 file.display()
380 );
381 println!(" {DIM}(\u{2212} stored, + local){RESET}");
382 for line in &differences {
383 println!("{line}");
384 }
385 Err(format!("{} field(s) differ", differences.len()).into())
386}
387
388fn walk_json_diff(
392 path: &str,
393 remote: &serde_json::Value,
394 local: &serde_json::Value,
395 out: &mut Vec<String>,
396) {
397 use serde_json::Value;
398 match (remote, local) {
399 (Value::Object(a), Value::Object(b)) => {
400 let mut keys: std::collections::BTreeSet<&String> = a.keys().collect();
401 keys.extend(b.keys());
402 for key in keys {
403 let child_path = if path.is_empty() {
404 key.clone()
405 } else {
406 format!("{path}.{key}")
407 };
408 match (a.get(key), b.get(key)) {
409 (Some(av), Some(bv)) => walk_json_diff(&child_path, av, bv, out),
410 (Some(av), None) => {
411 out.push(format!(" {RED}\u{2212}{RESET} {child_path} = {av}"));
412 }
413 (None, Some(bv)) => {
414 out.push(format!(" {GREEN}+{RESET} {child_path} = {bv}"));
415 }
416 (None, None) => unreachable!(),
417 }
418 }
419 }
420 (Value::Array(a), Value::Array(b)) => {
421 if a.len() != b.len() {
422 out.push(format!(
423 " {YELLOW}~{RESET} {path}: array length {} \u{2192} {}",
424 a.len(),
425 b.len()
426 ));
427 return;
428 }
429 for (i, (av, bv)) in a.iter().zip(b.iter()).enumerate() {
430 walk_json_diff(&format!("{path}[{i}]"), av, bv, out);
431 }
432 }
433 (a, b) if a == b => {}
434 (a, b) => {
435 out.push(format!(
436 " {RED}\u{2212}{RESET} {path} = {a}\n {GREEN}+{RESET} {path} = {b}"
437 ));
438 }
439 }
440}
441
442pub async fn cmd_delete(
444 client: &VtaClient,
445 name: &str,
446 context: Option<&str>,
447) -> Result<(), Box<dyn std::error::Error>> {
448 match context {
449 Some(ctx) => client.delete_context_did_template(ctx, name).await?,
450 None => client.delete_did_template(name).await?,
451 }
452 println!(
453 "{GREEN}\u{2713}{RESET} Deleted {CYAN}'{name}'{RESET} from {}.",
454 scope_label(context)
455 );
456 Ok(())
457}
458
459pub fn cmd_list_builtins() -> Result<(), Box<dyn std::error::Error>> {
465 let dim = Style::default().fg(Color::DarkGray);
466 let header_style = Style::default()
467 .fg(Color::White)
468 .add_modifier(Modifier::BOLD);
469 let header = Row::new(vec!["Name", "Kind", "Required vars", "Description"])
470 .style(header_style)
471 .bottom_margin(1);
472
473 let mut rows: Vec<Row> = Vec::with_capacity(BUILTIN_NAMES.len());
474 for name in BUILTIN_NAMES {
475 let tpl = load_embedded(name)?;
476 let required = if tpl.required_vars.is_empty() {
477 "\u{2014}".to_string()
478 } else {
479 tpl.required_vars.join(", ")
480 };
481 let description = tpl.description.clone().unwrap_or_else(|| "\u{2014}".into());
482 rows.push(Row::new(vec![
483 Cell::from(name.to_string()).style(Style::default().fg(Color::Cyan)),
484 Cell::from(tpl.kind),
485 Cell::from(required).style(dim),
486 Cell::from(description),
487 ]));
488 }
489
490 let title = format!(" Built-in DID templates ({}) ", BUILTIN_NAMES.len());
491 let table = Table::new(
492 rows,
493 [
494 Constraint::Length(24), Constraint::Length(16), Constraint::Length(24), Constraint::Min(40), ],
499 )
500 .header(header)
501 .column_spacing(2)
502 .block(Block::bordered().title(title).border_style(dim));
503
504 let height = BUILTIN_NAMES.len() as u16 + 4;
505 print_widget(table, height);
506 Ok(())
507}