Skip to main content

vta_cli_common/commands/
did_templates.rs

1//! DID template commands — offline (Phase 1) and online (Phase 2 global scope).
2//!
3//! Offline: validate a file, init a starter from an embedded builtin, list
4//! builtins. Online: list/show/create/update/delete/render against the VTA.
5//!
6//! # Output style
7//!
8//! Follows the workspace CLI style guide: **list operations emit a ratatui
9//! table**, **detail views emit aligned key-value lines**, **actions emit a
10//! short `✓`-prefixed confirmation**. See `docs/04-reference/cli-style.md`.
11
12use 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
27/// `pnm did-templates validate <file>` / `cnm did-templates validate <file>`.
28///
29/// Loads a template JSON file, runs the structural + semantic validator, and
30/// reports pass/fail. Never touches the network.
31pub 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
62/// `pnm did-templates init <kind>` / `cnm did-templates init <kind>`.
63///
64/// Emit a starter template on stdout by forking an embedded built-in. The
65/// operator can redirect to a file, edit, and upload. `kind` is a built-in
66/// name (`didcomm-mediator`, `did-hosting-control`, `did-hosting-daemon`,
67/// `did-hosting-server`).
68pub fn cmd_init(kind: String) -> Result<(), Box<dyn std::error::Error>> {
69    // Accept either the exact builtin name, a short alias, or a legacy
70    // webvh-* name (resolved via the builtin loader's alias table).
71    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        // Legacy aliases — silently resolve to the renamed templates.
78        "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    // Load the builtin, re-serialize as pretty-printed JSON for editing.
93    let tpl = load_embedded(builtin_name)?;
94    let pretty = serde_json::to_string_pretty(&tpl)?;
95    println!("{pretty}");
96
97    // Hint goes to stderr so stdout stays redirect-friendly.
98    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
107// ── Online (global scope — Phase 2; context scope — Phase 3) ────────
108
109fn scope_label(context: Option<&str>) -> String {
110    context
111        .map(|c| format!("context '{c}'"))
112        .unwrap_or_else(|| "global".into())
113}
114
115/// `pnm did-templates list [--context X]` — show stored templates.
116///
117/// Without `--context`, lists global-scope templates. With `--context X`,
118/// lists templates scoped to that context. Built-ins are not merged in —
119/// use `list-builtins`. Keeping scopes visually distinct makes it obvious
120/// whether a template is cross-context (global), context-local, or
121/// embedded.
122pub 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),    // Name
214            Constraint::Length(16), // Kind
215            Constraint::Min(24),    // Required vars
216            Constraint::Length(26), // Created (local tz with offset)
217        ],
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
228/// `pnm did-templates show <name> [--context X] [--rendered --var K=V ...]` —
229/// fetch one template, optionally rendered.
230pub 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        // DID / SIGNING_KEY_MB / KA_KEY_MB are reserved ambient vars the
243        // server will fill from Phase 4 onward. Until then, supply them via
244        // --var to preview what a rendered document will look like.
245        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
266/// `pnm did-templates create --file <path> [--context X]` — upload a template.
267///
268/// The file is validated locally before upload so authoring errors fail
269/// fast without burning a round-trip to a super-admin ACL check.
270pub 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
290/// `pnm did-templates update <name> --file <path> [--context X]` — replace a template.
291pub 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
318/// `pnm did-templates export <name> [--context X]` — emit a portable JSON
319/// file of a stored template, stripping server provenance (scope, timestamps,
320/// author DID). The output shape matches what `init` emits, so `export | edit
321/// | create --file -` round-trips without a format conversion step.
322///
323/// Writes to stdout so operators can redirect to a file or pipe through
324/// `jq`/`diff`. Never audits.
325pub 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
339/// `pnm did-templates diff <name> --file <path> [--context X]` — compare a
340/// local template file against what the VTA has stored. Walks the parsed JSON
341/// in parallel and reports every path whose value differs.
342///
343/// Exits non-zero when the two templates differ, so the command plugs into
344/// scripts ("is my local copy in sync?"). No changes → exit 0, silent stdout.
345pub 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    // Load local first — if the file is malformed, fail fast without burning
352    // a round-trip.
353    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
388/// Recursive JSON walker that reports every leaf path where `remote` and
389/// `local` disagree. Arrays are compared element-wise; length mismatches
390/// are reported as a single line.
391fn 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
442/// `pnm did-templates delete <name> [--context X]` — remove a stored template.
443pub 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
459// ── Offline (Phase 1 helpers) ───────────────────────────────────────
460
461/// `pnm did-templates list-builtins`.
462///
463/// Show the names of every built-in template shipped with this SDK.
464pub 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), // Name
495            Constraint::Length(16), // Kind
496            Constraint::Length(24), // Required vars
497            Constraint::Min(40),    // Description
498        ],
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}