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
62fn resolve_builtin_kind(kind: &str) -> Option<&'static str> {
76 let name = match kind {
77 "mediator" => "didcomm-mediator",
78 "agent" => "ai-agent",
79 "did-hosting" | "hosting" | "daemon" => "did-host-http",
80 "control" => "did-host-http-didcomm",
81 "witness" | "watcher" | "server" => "did-host-didcomm",
82 other => *BUILTIN_NAMES.iter().find(|n| **n == other)?,
83 };
84 Some(name)
85}
86
87pub fn cmd_init(kind: String) -> Result<(), Box<dyn std::error::Error>> {
95 let Some(builtin_name) = resolve_builtin_kind(&kind) else {
96 eprintln!(
97 "{RED}\u{2717}{RESET} Unknown builtin kind '{kind}'. Available: {}",
98 BUILTIN_NAMES.join(", ")
99 );
100 return Err("unknown builtin".into());
101 };
102
103 let tpl = load_embedded(builtin_name)?;
105 let pretty = serde_json::to_string_pretty(&tpl)?;
106 println!("{pretty}");
107
108 eprintln!();
110 eprintln!(
111 "{YELLOW}Tip:{RESET} redirect to a file and edit the {DIM}name{RESET}, {DIM}description{RESET},"
112 );
113 eprintln!(" and any placeholder values before uploading. For example:");
114 eprintln!(" pnm did-templates init {kind} > my-{builtin_name}.json");
115 Ok(())
116}
117
118fn scope_label(context: Option<&str>) -> String {
121 context
122 .map(|c| format!("context '{c}'"))
123 .unwrap_or_else(|| "global".into())
124}
125
126pub async fn cmd_list(
134 client: &VtaClient,
135 context: Option<&str>,
136) -> Result<(), Box<dyn std::error::Error>> {
137 let records = match context {
138 Some(ctx) => client.list_context_did_templates(ctx).await?,
139 None => client.list_did_templates().await?,
140 };
141
142 if crate::render::is_json_output() {
143 crate::render::print_json(&records)?;
144 return Ok(());
145 }
146
147 if records.is_empty() {
148 match context {
149 Some(ctx) => println!("No DID templates stored in context '{ctx}'."),
150 None => println!("No DID templates stored on the VTA."),
151 }
152 println!(" {DIM}Scaffold one with{RESET} `pnm did-templates init <kind> > tpl.json`,");
153 let create_hint = match context {
154 Some(ctx) => format!("pnm did-templates create --context {ctx} --file tpl.json"),
155 None => "pnm did-templates create --file tpl.json".into(),
156 };
157 println!(" {DIM}then{RESET} `{create_hint}`.");
158 return Ok(());
159 }
160
161 if is_full_display() {
162 let title = match context {
163 Some(ctx) => format!("DID templates in context '{ctx}'"),
164 None => "Stored DID templates (global)".to_string(),
165 };
166 print_full_list_title(&title, records.len());
167 for r in &records {
168 let required = if r.template.required_vars.is_empty() {
169 "—".to_string()
170 } else {
171 r.template.required_vars.join(", ")
172 };
173 let description = r
174 .template
175 .description
176 .clone()
177 .unwrap_or_else(|| "—".to_string());
178 let created = format_local_time(r.created_at);
179 print_full_entry(&[
180 ("Name", &r.template.name),
181 ("Kind", &r.template.kind),
182 ("Description", &description),
183 ("Required vars", &required),
184 ("Created", &created),
185 ("Created by", &r.created_by),
186 ]);
187 }
188 return Ok(());
189 }
190
191 let dim = Style::default().fg(Color::DarkGray);
192 let header_style = Style::default()
193 .fg(Color::White)
194 .add_modifier(Modifier::BOLD);
195 let header = Row::new(vec!["Name", "Kind", "Required vars", "Created"])
196 .style(header_style)
197 .bottom_margin(1);
198
199 let rows: Vec<Row> = records
200 .iter()
201 .map(|r| {
202 let required = if r.template.required_vars.is_empty() {
203 "\u{2014}".to_string()
204 } else {
205 r.template.required_vars.join(", ")
206 };
207 let created = format_local_time(r.created_at);
208 Row::new(vec![
209 Cell::from(r.template.name.clone()).style(Style::default().fg(Color::Cyan)),
210 Cell::from(r.template.kind.clone()),
211 Cell::from(required).style(dim),
212 Cell::from(created).style(dim),
213 ])
214 })
215 .collect();
216
217 let title = match context {
218 Some(ctx) => format!(" DID templates in context '{ctx}' ({}) ", records.len()),
219 None => format!(" Stored DID templates (global) ({}) ", records.len()),
220 };
221 let table = Table::new(
222 rows,
223 [
224 Constraint::Min(24), Constraint::Length(16), Constraint::Min(24), Constraint::Length(26), ],
229 )
230 .header(header)
231 .column_spacing(2)
232 .block(Block::bordered().title(title).border_style(dim));
233
234 let height = records.len() as u16 + 4;
235 print_widget(table, height);
236 Ok(())
237}
238
239pub async fn cmd_show(
242 client: &VtaClient,
243 name: &str,
244 context: Option<&str>,
245 rendered: bool,
246 vars: Vec<(String, String)>,
247) -> Result<(), Box<dyn std::error::Error>> {
248 if rendered {
249 let mut vars_map: HashMap<String, serde_json::Value> = HashMap::new();
250 for (k, v) in vars {
251 vars_map.insert(k, serde_json::Value::String(v));
252 }
253 let doc = match context {
257 Some(ctx) => {
258 client
259 .render_context_did_template(ctx, name, vars_map)
260 .await?
261 }
262 None => client.render_did_template(name, vars_map).await?,
263 };
264 println!("{}", serde_json::to_string_pretty(&doc)?);
265 return Ok(());
266 }
267
268 let r = match context {
269 Some(ctx) => client.get_context_did_template(ctx, name).await?,
270 None => client.get_did_template(name).await?,
271 };
272 let pretty = serde_json::to_string_pretty(&r)?;
273 println!("{pretty}");
274 Ok(())
275}
276
277pub async fn cmd_create(
282 client: &VtaClient,
283 context: Option<&str>,
284 file: PathBuf,
285) -> Result<(), Box<dyn std::error::Error>> {
286 let tpl = DidTemplate::load_file(&file)
287 .map_err(|e| format!("template at {} is invalid: {e}", file.display()))?;
288 let record = match context {
289 Some(ctx) => client.create_context_did_template(ctx, tpl).await?,
290 None => client.create_did_template(tpl).await?,
291 };
292 println!(
293 "{GREEN}\u{2713}{RESET} Created {CYAN}'{}'{RESET} ({DIM}{}{RESET}) in {}.",
294 record.template.name,
295 record.template.kind,
296 scope_label(context)
297 );
298 Ok(())
299}
300
301pub async fn cmd_update(
303 client: &VtaClient,
304 name: &str,
305 context: Option<&str>,
306 file: PathBuf,
307) -> Result<(), Box<dyn std::error::Error>> {
308 let tpl = DidTemplate::load_file(&file)
309 .map_err(|e| format!("template at {} is invalid: {e}", file.display()))?;
310 if tpl.name != name {
311 return Err(format!(
312 "file's template name '{}' does not match --name argument '{}'",
313 tpl.name, name
314 )
315 .into());
316 }
317 let record = match context {
318 Some(ctx) => client.update_context_did_template(ctx, name, tpl).await?,
319 None => client.update_did_template(name, tpl).await?,
320 };
321 println!(
322 "{GREEN}\u{2713}{RESET} Updated {CYAN}'{}'{RESET} in {}.",
323 record.template.name,
324 scope_label(context)
325 );
326 Ok(())
327}
328
329pub async fn cmd_export(
337 client: &VtaClient,
338 name: &str,
339 context: Option<&str>,
340) -> Result<(), Box<dyn std::error::Error>> {
341 let record = match context {
342 Some(ctx) => client.get_context_did_template(ctx, name).await?,
343 None => client.get_did_template(name).await?,
344 };
345 let pretty = serde_json::to_string_pretty(&record.template)?;
346 println!("{pretty}");
347 Ok(())
348}
349
350pub async fn cmd_diff(
357 client: &VtaClient,
358 name: &str,
359 context: Option<&str>,
360 file: PathBuf,
361) -> Result<(), Box<dyn std::error::Error>> {
362 let local = DidTemplate::load_file(&file)
365 .map_err(|e| format!("local template at {} is invalid: {e}", file.display()))?;
366
367 let remote_record = match context {
368 Some(ctx) => client.get_context_did_template(ctx, name).await?,
369 None => client.get_did_template(name).await?,
370 };
371 let remote = remote_record.template;
372
373 let remote_val = serde_json::to_value(&remote)?;
374 let local_val = serde_json::to_value(&local)?;
375
376 let mut differences = Vec::new();
377 walk_json_diff("", &remote_val, &local_val, &mut differences);
378
379 if differences.is_empty() {
380 println!(
381 "{GREEN}\u{2713}{RESET} Local {CYAN}'{name}'{RESET} matches stored {}.",
382 scope_label(context)
383 );
384 return Ok(());
385 }
386
387 println!(
388 "{YELLOW}Differences{RESET} between stored {CYAN}'{name}'{RESET} ({}) and {}:",
389 scope_label(context),
390 file.display()
391 );
392 println!(" {DIM}(\u{2212} stored, + local){RESET}");
393 for line in &differences {
394 println!("{line}");
395 }
396 Err(format!("{} field(s) differ", differences.len()).into())
397}
398
399fn walk_json_diff(
403 path: &str,
404 remote: &serde_json::Value,
405 local: &serde_json::Value,
406 out: &mut Vec<String>,
407) {
408 use serde_json::Value;
409 match (remote, local) {
410 (Value::Object(a), Value::Object(b)) => {
411 let mut keys: std::collections::BTreeSet<&String> = a.keys().collect();
412 keys.extend(b.keys());
413 for key in keys {
414 let child_path = if path.is_empty() {
415 key.clone()
416 } else {
417 format!("{path}.{key}")
418 };
419 match (a.get(key), b.get(key)) {
420 (Some(av), Some(bv)) => walk_json_diff(&child_path, av, bv, out),
421 (Some(av), None) => {
422 out.push(format!(" {RED}\u{2212}{RESET} {child_path} = {av}"));
423 }
424 (None, Some(bv)) => {
425 out.push(format!(" {GREEN}+{RESET} {child_path} = {bv}"));
426 }
427 (None, None) => unreachable!(),
428 }
429 }
430 }
431 (Value::Array(a), Value::Array(b)) => {
432 if a.len() != b.len() {
433 out.push(format!(
434 " {YELLOW}~{RESET} {path}: array length {} \u{2192} {}",
435 a.len(),
436 b.len()
437 ));
438 return;
439 }
440 for (i, (av, bv)) in a.iter().zip(b.iter()).enumerate() {
441 walk_json_diff(&format!("{path}[{i}]"), av, bv, out);
442 }
443 }
444 (a, b) if a == b => {}
445 (a, b) => {
446 out.push(format!(
447 " {RED}\u{2212}{RESET} {path} = {a}\n {GREEN}+{RESET} {path} = {b}"
448 ));
449 }
450 }
451}
452
453pub async fn cmd_delete(
455 client: &VtaClient,
456 name: &str,
457 context: Option<&str>,
458) -> Result<(), Box<dyn std::error::Error>> {
459 match context {
460 Some(ctx) => client.delete_context_did_template(ctx, name).await?,
461 None => client.delete_did_template(name).await?,
462 }
463 println!(
464 "{GREEN}\u{2713}{RESET} Deleted {CYAN}'{name}'{RESET} from {}.",
465 scope_label(context)
466 );
467 Ok(())
468}
469
470pub fn cmd_list_builtins() -> Result<(), Box<dyn std::error::Error>> {
476 let dim = Style::default().fg(Color::DarkGray);
477 let header_style = Style::default()
478 .fg(Color::White)
479 .add_modifier(Modifier::BOLD);
480 let header = Row::new(vec!["Name", "Kind", "Required vars", "Description"])
481 .style(header_style)
482 .bottom_margin(1);
483
484 let mut rows: Vec<Row> = Vec::with_capacity(BUILTIN_NAMES.len());
485 for name in BUILTIN_NAMES {
486 let tpl = load_embedded(name)?;
487 let required = if tpl.required_vars.is_empty() {
488 "\u{2014}".to_string()
489 } else {
490 tpl.required_vars.join(", ")
491 };
492 let description = tpl.description.clone().unwrap_or_else(|| "\u{2014}".into());
493 rows.push(Row::new(vec![
494 Cell::from(name.to_string()).style(Style::default().fg(Color::Cyan)),
495 Cell::from(tpl.kind),
496 Cell::from(required).style(dim),
497 Cell::from(description),
498 ]));
499 }
500
501 let title = format!(" Built-in DID templates ({}) ", BUILTIN_NAMES.len());
502 let table = Table::new(
503 rows,
504 [
505 Constraint::Length(24), Constraint::Length(16), Constraint::Length(24), Constraint::Min(40), ],
510 )
511 .header(header)
512 .column_spacing(2)
513 .block(Block::bordered().title(title).border_style(dim));
514
515 let height = BUILTIN_NAMES.len() as u16 + 4;
516 print_widget(table, height);
517 Ok(())
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
533 fn every_init_alias_resolves_to_a_loadable_builtin() {
534 for alias in [
535 "mediator",
536 "agent",
537 "did-hosting",
538 "hosting",
539 "daemon",
540 "control",
541 "witness",
542 "watcher",
543 "server",
544 ] {
545 let resolved = resolve_builtin_kind(alias)
546 .unwrap_or_else(|| panic!("alias '{alias}' resolved to nothing"));
547 assert!(
548 BUILTIN_NAMES.contains(&resolved),
549 "alias '{alias}' → '{resolved}', which is not in BUILTIN_NAMES"
550 );
551 assert!(
552 load_embedded(resolved).is_ok(),
553 "alias '{alias}' → '{resolved}', which the loader cannot load"
554 );
555 }
556 }
557
558 #[test]
560 fn canonical_builtin_names_pass_through() {
561 for name in BUILTIN_NAMES {
562 assert_eq!(
563 resolve_builtin_kind(name),
564 Some(*name),
565 "canonical name '{name}' must pass through unchanged"
566 );
567 }
568 }
569
570 #[test]
573 fn retired_webvh_aliases_are_rejected() {
574 for old in [
575 "webvh-hosting",
576 "webvh-control",
577 "webvh-daemon",
578 "webvh-server",
579 "did-hosting-control",
580 "did-hosting-daemon",
581 "did-hosting-server",
582 ] {
583 assert_eq!(
584 resolve_builtin_kind(old),
585 None,
586 "retired alias '{old}' must no longer resolve"
587 );
588 }
589 }
590}