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/// Resolve an `init <kind>` argument to an exact [`BUILTIN_NAMES`] entry.
63///
64/// Accepts either the canonical name verbatim or a short role alias. Returns
65/// `None` for anything else.
66///
67/// Role aliases map onto the canonical *shape* names, where `http` = a
68/// WebVHHosting endpoint and `didcomm` = a DIDCommMessaging endpoint: a control
69/// node serves both, a daemon publishes DID logs only, and a witness/watcher is
70/// reached over DIDComm only.
71///
72/// The legacy `webvh-*` names were retired after their one-release deprecation
73/// window, matching the builtin loader — see
74/// `did_templates::builtin::legacy_template_aliases_are_removed`.
75fn 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
87/// `pnm did-templates init <kind>` / `cnm did-templates init <kind>`.
88///
89/// Emit a starter template on stdout by forking an embedded built-in. The
90/// operator can redirect to a file, edit, and upload. `kind` is a canonical
91/// built-in name (`didcomm-mediator`, `did-host-http`, `did-host-http-didcomm`,
92/// `did-host-didcomm`, …) or a short role alias — see
93/// [`resolve_builtin_kind`].
94pub 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    // Load the builtin, re-serialize as pretty-printed JSON for editing.
104    let tpl = load_embedded(builtin_name)?;
105    let pretty = serde_json::to_string_pretty(&tpl)?;
106    println!("{pretty}");
107
108    // Hint goes to stderr so stdout stays redirect-friendly.
109    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
118// ── Online (global scope — Phase 2; context scope — Phase 3) ────────
119
120fn scope_label(context: Option<&str>) -> String {
121    context
122        .map(|c| format!("context '{c}'"))
123        .unwrap_or_else(|| "global".into())
124}
125
126/// `pnm did-templates list [--context X]` — show stored templates.
127///
128/// Without `--context`, lists global-scope templates. With `--context X`,
129/// lists templates scoped to that context. Built-ins are not merged in —
130/// use `list-builtins`. Keeping scopes visually distinct makes it obvious
131/// whether a template is cross-context (global), context-local, or
132/// embedded.
133pub 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),    // Name
225            Constraint::Length(16), // Kind
226            Constraint::Min(24),    // Required vars
227            Constraint::Length(26), // Created (local tz with offset)
228        ],
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
239/// `pnm did-templates show <name> [--context X] [--rendered --var K=V ...]` —
240/// fetch one template, optionally rendered.
241pub 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        // DID / SIGNING_KEY_MB / KA_KEY_MB are reserved ambient vars the
254        // server will fill from Phase 4 onward. Until then, supply them via
255        // --var to preview what a rendered document will look like.
256        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
277/// `pnm did-templates create --file <path> [--context X]` — upload a template.
278///
279/// The file is validated locally before upload so authoring errors fail
280/// fast without burning a round-trip to a super-admin ACL check.
281pub 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
301/// `pnm did-templates update <name> --file <path> [--context X]` — replace a template.
302pub 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
329/// `pnm did-templates export <name> [--context X]` — emit a portable JSON
330/// file of a stored template, stripping server provenance (scope, timestamps,
331/// author DID). The output shape matches what `init` emits, so `export | edit
332/// | create --file -` round-trips without a format conversion step.
333///
334/// Writes to stdout so operators can redirect to a file or pipe through
335/// `jq`/`diff`. Never audits.
336pub 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
350/// `pnm did-templates diff <name> --file <path> [--context X]` — compare a
351/// local template file against what the VTA has stored. Walks the parsed JSON
352/// in parallel and reports every path whose value differs.
353///
354/// Exits non-zero when the two templates differ, so the command plugs into
355/// scripts ("is my local copy in sync?"). No changes → exit 0, silent stdout.
356pub 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    // Load local first — if the file is malformed, fail fast without burning
363    // a round-trip.
364    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
399/// Recursive JSON walker that reports every leaf path where `remote` and
400/// `local` disagree. Arrays are compared element-wise; length mismatches
401/// are reported as a single line.
402fn 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
453/// `pnm did-templates delete <name> [--context X]` — remove a stored template.
454pub 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
470// ── Offline (Phase 1 helpers) ───────────────────────────────────────
471
472/// `pnm did-templates list-builtins`.
473///
474/// Show the names of every built-in template shipped with this SDK.
475pub 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), // Name
506            Constraint::Length(16), // Kind
507            Constraint::Length(24), // Required vars
508            Constraint::Min(40),    // Description
509        ],
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    /// **Every** `init` alias must resolve to a builtin the loader actually
525    /// has. This is the check that was missing: the `did-hosting-*` →
526    /// `did-host-*` rename updated `BUILTIN_NAMES` and `load_embedded` but not
527    /// this alias table, so seven of the nine aliases resolved to names that no
528    /// longer existed and `init` failed with "builtin template not found".
529    ///
530    /// Asserting against `load_embedded` rather than a hardcoded list is the
531    /// point — a future rename that misses one side fails here.
532    #[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    /// Every canonical name is accepted verbatim, not just via an alias.
559    #[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    /// The retired `webvh-*` aliases must not come back. Their one-release
571    /// window closed; a stale name should fail loudly rather than resolve.
572    #[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}