Skip to main content

vta_cli_common/commands/
webvh_edit.rs

1//! Shared helpers for the `webvh edit-did` command.
2//!
3//! Two flows live here, both producing an [`UpdateDidWebvhBody`]
4//! that the caller hands to either the SDK client (online) or the
5//! local op (offline):
6//!
7//! 1. **Interactive** — pull the latest LogEntry's published DID
8//!    document, open it in `$EDITOR` via `dialoguer::Editor`, then
9//!    walk a `Confirm`/`Input` chain asking the operator about the
10//!    webvh parameters (pre-rotation count, watcher set, TTL,
11//!    audit label).
12//!
13//! 2. **Non-interactive** — load a JSON document from a file and
14//!    apply CLI flags for the parameters. Suited for scripted
15//!    flows / CI / TEE-host operators.
16//!
17//! Both flows enforce the **DID-id invariant**: the operator can
18//! change everything about the document *except* the top-level
19//! `id` field. Mutating the DID identifier mid-stream would
20//! invalidate every existing reference to it; the WebVH spec
21//! treats it as a permanent commitment from the first LogEntry.
22
23use serde_json::Value;
24
25use vta_sdk::protocols::did_management::update::UpdateDidWebvhBody;
26
27/// Errors from the editor flow. Variant strings are operator-facing
28/// — formatted with leading lowercase so they read naturally after
29/// "Error: " in the CLI output.
30#[derive(Debug, thiserror::Error)]
31pub enum EditFlowError {
32    #[error("DID log is empty — cannot extract the current document")]
33    EmptyLog,
34    #[error("DID log line parse: {0}")]
35    LogParse(String),
36    #[error("DID document missing `id` field")]
37    DocumentMissingId,
38    #[error(
39        "the edited document changed the DID identifier (`{prior}` → `{edited}`). \
40         The DID id is a permanent commitment from the first LogEntry; mutating it \
41         would invalidate every existing reference to the DID. Re-run the editor \
42         with the original `id` restored, or use `pnm did-mgmt dids create` to mint a \
43         new DID instead."
44    )]
45    DidIdChanged { prior: String, edited: String },
46    #[error("edited content is not valid JSON: {0}")]
47    InvalidJson(String),
48    #[error("editor was cancelled — nothing to publish")]
49    EditorCancelled,
50    #[error("could not read document file `{path}`: {source}")]
51    ReadFile {
52        path: String,
53        source: std::io::Error,
54    },
55    #[error("could not read options file `{path}`: {source}")]
56    ReadOptions {
57        path: String,
58        source: std::io::Error,
59    },
60    #[error("invalid options JSON in `{path}`: {source}")]
61    InvalidOptions {
62        path: String,
63        source: serde_json::Error,
64    },
65    #[error("publish cancelled by operator")]
66    PublishCancelled,
67    #[error("interactive prompt failed: {0}")]
68    Prompt(String),
69}
70
71/// Extract the published DID document from the most recent
72/// non-empty line of a `did.jsonl` log. The line is parsed as a
73/// LogEntry and its `state` field is returned.
74///
75/// Implemented inline (rather than calling didwebvh-rs) because
76/// vta-cli-common doesn't depend on that crate; the LogEntry
77/// surface we need is just the `state` JSON value.
78pub fn extract_current_document(did_log: &str) -> Result<Value, EditFlowError> {
79    let line = did_log
80        .lines()
81        .rfind(|l| !l.trim().is_empty())
82        .ok_or(EditFlowError::EmptyLog)?;
83    let entry: Value = serde_json::from_str(line)
84        .map_err(|e| EditFlowError::LogParse(format!("line parse: {e}")))?;
85    let state = entry
86        .get("state")
87        .cloned()
88        .ok_or_else(|| EditFlowError::LogParse("LogEntry has no `state` field".into()))?;
89    Ok(state)
90}
91
92/// Extract the `versionId` of the last non-empty log entry. Used as
93/// the optimistic-concurrency precondition on the save call: the VTA
94/// rejects the update if the DID has moved on since this versionId.
95/// Returns `Err(EmptyLog)` when the log has no entries; returns
96/// `Err(LogParse)` if the latest entry is malformed or missing
97/// `versionId` (extremely unlikely on a valid did:webvh log — but
98/// fall back to None at the call site rather than blocking the save
99/// just because we couldn't read a version).
100pub fn extract_latest_version_id(did_log: &str) -> Result<String, EditFlowError> {
101    let line = did_log
102        .lines()
103        .rfind(|l| !l.trim().is_empty())
104        .ok_or(EditFlowError::EmptyLog)?;
105    let entry: Value = serde_json::from_str(line)
106        .map_err(|e| EditFlowError::LogParse(format!("line parse: {e}")))?;
107    entry
108        .get("versionId")
109        .and_then(Value::as_str)
110        .map(str::to_string)
111        .ok_or_else(|| EditFlowError::LogParse("LogEntry has no `versionId` field".into()))
112}
113
114/// Summary of the DID's currently-effective pre-rotation setup,
115/// extracted from the on-disk log without needing to depend on
116/// didwebvh-rs. Surfaced before the "Override pre-rotation count?"
117/// prompt so the operator can see what they're choosing to change.
118///
119/// Pre-rotation is "active" when the most recent log entry that
120/// mentions `nextKeyHashes` has a non-empty array. Walking back
121/// through entries handles the did:webvh delta-parameter model
122/// (subsequent entries may omit unchanged fields).
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct PreRotationStatus {
125    /// Number of pre-rotation keys committed in the most recent
126    /// non-empty commitment. `0` when pre-rotation is disabled or
127    /// has never been enabled.
128    pub committed_count: usize,
129    /// True iff the most recent mention of `nextKeyHashes` was
130    /// non-empty. False when the most recent mention was an empty
131    /// array (explicit disable) or when no entry has ever mentioned
132    /// the field.
133    pub active: bool,
134    /// True when no entry in the log has ever mentioned
135    /// `nextKeyHashes` — distinguishes "never enabled" from
136    /// "explicitly disabled" for the operator-facing display.
137    pub never_set: bool,
138}
139
140/// Walk the log latest-first to find the most recent entry that
141/// declared `nextKeyHashes`. The latest such declaration is
142/// authoritative under did:webvh delta-parameter semantics.
143pub fn extract_pre_rotation_status(did_log: &str) -> PreRotationStatus {
144    for line in did_log.lines().rev() {
145        let trimmed = line.trim();
146        if trimmed.is_empty() {
147            continue;
148        }
149        let Ok(entry) = serde_json::from_str::<Value>(trimmed) else {
150            continue;
151        };
152        let Some(hashes) = entry
153            .get("parameters")
154            .and_then(|p| p.get("nextKeyHashes"))
155            .and_then(Value::as_array)
156        else {
157            continue;
158        };
159        let count = hashes.len();
160        return PreRotationStatus {
161            committed_count: count,
162            active: count > 0,
163            never_set: false,
164        };
165    }
166    PreRotationStatus {
167        committed_count: 0,
168        active: false,
169        never_set: true,
170    }
171}
172
173/// Read the `id` field from a DID document. Used to enforce the
174/// DID-id invariant after the operator edits the document.
175pub fn document_id(doc: &Value) -> Result<&str, EditFlowError> {
176    doc.get("id")
177        .and_then(Value::as_str)
178        .ok_or(EditFlowError::DocumentMissingId)
179}
180
181/// Verify that the edited document carries the same `id` as the
182/// prior version. Returns `Err(DidIdChanged)` if mutated.
183pub fn assert_did_id_unchanged(prior: &Value, edited: &Value) -> Result<(), EditFlowError> {
184    let prior_id = document_id(prior)?;
185    let edited_id = document_id(edited)?;
186    if prior_id != edited_id {
187        return Err(EditFlowError::DidIdChanged {
188            prior: prior_id.to_string(),
189            edited: edited_id.to_string(),
190        });
191    }
192    Ok(())
193}
194
195/// Open `initial` in `$EDITOR` (via `dialoguer::Editor`), parse
196/// the result as JSON, and verify the DID `id` wasn't mutated.
197/// Returns `Ok(None)` when the operator cancels (saves an empty
198/// buffer) — the caller treats that as "abort, don't publish."
199pub fn launch_editor(prior_doc: &Value) -> Result<Option<Value>, EditFlowError> {
200    let pretty = serde_json::to_string_pretty(prior_doc).map_err(|e| {
201        EditFlowError::Prompt(format!(
202            "could not serialise current document for editor: {e}"
203        ))
204    })?;
205
206    let edited = dialoguer::Editor::new()
207        .extension(".json")
208        .edit(&pretty)
209        .map_err(|e| EditFlowError::Prompt(format!("editor launch failed: {e}")))?;
210
211    let Some(raw) = edited else {
212        // Operator quit without saving (or saved empty). Treat as
213        // cancel — don't publish.
214        return Ok(None);
215    };
216    if raw.trim().is_empty() {
217        return Err(EditFlowError::EditorCancelled);
218    }
219    let edited_doc: Value =
220        serde_json::from_str(&raw).map_err(|e| EditFlowError::InvalidJson(e.to_string()))?;
221    assert_did_id_unchanged(prior_doc, &edited_doc)?;
222    Ok(Some(edited_doc))
223}
224
225/// Operator-facing summary of how many top-level fields differ
226/// between the prior and edited documents. Cheap heuristic — the
227/// CLI prints it before asking "looks good?" so the operator has
228/// a sanity check on what they touched.
229pub fn diff_summary(prior: &Value, edited: &Value) -> String {
230    let prior_obj = prior.as_object();
231    let edited_obj = edited.as_object();
232    let (Some(prior_obj), Some(edited_obj)) = (prior_obj, edited_obj) else {
233        return "(non-object document — diff unavailable)".to_string();
234    };
235
236    let mut added = Vec::new();
237    let mut changed = Vec::new();
238    let mut removed = Vec::new();
239
240    for (k, v) in edited_obj {
241        match prior_obj.get(k) {
242            None => added.push(k.as_str()),
243            Some(prior_v) if prior_v != v => changed.push(k.as_str()),
244            Some(_) => {}
245        }
246    }
247    for k in prior_obj.keys() {
248        if !edited_obj.contains_key(k) {
249            removed.push(k.as_str());
250        }
251    }
252
253    if added.is_empty() && changed.is_empty() && removed.is_empty() {
254        return "(no top-level fields changed)".to_string();
255    }
256    let mut out = String::new();
257    if !added.is_empty() {
258        out.push_str(&format!("added: {}\n", added.join(", ")));
259    }
260    if !changed.is_empty() {
261        out.push_str(&format!("changed: {}\n", changed.join(", ")));
262    }
263    if !removed.is_empty() {
264        out.push_str(&format!("removed: {}\n", removed.join(", ")));
265    }
266    out.trim_end().to_string()
267}
268
269/// Non-interactive flag bundle. Fed into [`build_options_from_flags`]
270/// to produce an [`UpdateDidWebvhBody`]. Keeping this separate from
271/// `UpdateDidWebvhBody` avoids parser/clap concerns leaking into the
272/// SDK wire type.
273#[derive(Debug, Clone, Default)]
274pub struct EditFlags {
275    /// Path to a JSON file containing the new DID document. When set,
276    /// the editor is bypassed.
277    pub document_file: Option<std::path::PathBuf>,
278    /// Path to a JSON file containing a full `UpdateDidWebvhBody`.
279    /// Mutually exclusive with the per-field flags below; useful for
280    /// power users who want witness changes (witness shape requires
281    /// multibase ids and is awkward to express on the command line).
282    pub options_file: Option<std::path::PathBuf>,
283    pub pre_rotation: Option<u32>,
284    pub ttl: Option<u32>,
285    /// Replace the watcher set with these URLs. Mutually exclusive
286    /// with `no_watchers`.
287    pub watchers: Vec<String>,
288    /// Disable watchers entirely (sets `Some(vec![])` on the body).
289    pub no_watchers: bool,
290    pub label: Option<String>,
291}
292
293/// Build an [`UpdateDidWebvhBody`] from the non-interactive flag
294/// bundle. The operator either runs in interactive mode (no flags
295/// → editor + prompts), supplies a `--options-file` for full control
296/// (witnesses included), or uses the per-field flags below.
297pub fn build_options_from_flags(flags: &EditFlags) -> Result<UpdateDidWebvhBody, EditFlowError> {
298    if let Some(path) = &flags.options_file {
299        let raw = std::fs::read_to_string(path).map_err(|e| EditFlowError::ReadOptions {
300            path: path.display().to_string(),
301            source: e,
302        })?;
303        let body: UpdateDidWebvhBody =
304            serde_json::from_str(&raw).map_err(|e| EditFlowError::InvalidOptions {
305                path: path.display().to_string(),
306                source: e,
307            })?;
308        return Ok(body);
309    }
310
311    let document = match &flags.document_file {
312        Some(path) => {
313            let raw = std::fs::read_to_string(path).map_err(|e| EditFlowError::ReadFile {
314                path: path.display().to_string(),
315                source: e,
316            })?;
317            let v: Value = serde_json::from_str(&raw)
318                .map_err(|e| EditFlowError::InvalidJson(e.to_string()))?;
319            // Caller passes `prior_doc` separately when validating;
320            // here we just verify the edited shape has an `id`.
321            document_id(&v)?;
322            Some(v)
323        }
324        None => None,
325    };
326
327    let watchers = if flags.no_watchers {
328        Some(Vec::new())
329    } else if !flags.watchers.is_empty() {
330        Some(flags.watchers.clone())
331    } else {
332        None
333    };
334
335    Ok(UpdateDidWebvhBody {
336        document,
337        pre_rotation_count: flags.pre_rotation,
338        witnesses: None,
339        watchers,
340        ttl: flags.ttl,
341        label: flags.label.clone(),
342        // Non-interactive flag-driven path (e.g. `pnm webvh edit-did
343        // --document <file>` from a script). The interactive flow sets
344        // this to the fetched versionId so a stale `get → edit → save`
345        // cycle gets a 409; scripted callers opt in by passing
346        // `--expected-version-id` (wired separately).
347        expected_version_id: None,
348    })
349}
350
351/// Walk an interactive `Confirm`/`Input` chain asking the operator
352/// about the webvh parameters they want to change. `edited_doc` is
353/// the post-editor DID document (or `None` if the operator
354/// declined to edit). Returns the assembled
355/// [`UpdateDidWebvhBody`].
356///
357/// The chain is opt-in for every field: each starts with a
358/// `Confirm` defaulting to `false` so the operator can hit Enter
359/// repeatedly to skip everything and just publish the document
360/// edit on its own.
361pub fn prompt_webvh_params(
362    edited_doc: Option<Value>,
363    pre_rotation_status: Option<&PreRotationStatus>,
364) -> Result<UpdateDidWebvhBody, EditFlowError> {
365    use dialoguer::{Confirm, Input};
366
367    fn err(e: dialoguer::Error) -> EditFlowError {
368        EditFlowError::Prompt(e.to_string())
369    }
370
371    let mut body = UpdateDidWebvhBody {
372        document: edited_doc,
373        ..Default::default()
374    };
375
376    // Show the current pre-rotation setup so the operator can decide
377    // what (if anything) to change. Skipped when the caller didn't
378    // supply a status (e.g. an offline test path that has no log).
379    if let Some(s) = pre_rotation_status {
380        if s.never_set {
381            eprintln!("  Pre-rotation: disabled (never enabled on this DID).");
382        } else if !s.active {
383            eprintln!("  Pre-rotation: disabled (explicitly turned off).");
384        } else {
385            let plural = if s.committed_count == 1 {
386                "key"
387            } else {
388                "keys"
389            };
390            eprintln!(
391                "  Pre-rotation: active — {} {plural} currently committed.",
392                s.committed_count
393            );
394        }
395    }
396
397    if Confirm::new()
398        .with_prompt("Override pre-rotation count?")
399        .default(false)
400        .interact()
401        .map_err(err)?
402    {
403        let n: u32 = Input::new()
404            .with_prompt("New pre-rotation count (0 disables)")
405            .default(0)
406            .interact_text()
407            .map_err(err)?;
408        body.pre_rotation_count = Some(n);
409    }
410
411    if Confirm::new()
412        .with_prompt("Replace watcher URLs?")
413        .default(false)
414        .interact()
415        .map_err(err)?
416    {
417        let raw: String = Input::new()
418            .with_prompt("Comma-separated watcher URLs (empty input disables watchers entirely)")
419            .allow_empty(true)
420            .interact_text()
421            .map_err(err)?;
422        let watchers: Vec<String> = raw
423            .split(',')
424            .map(|s| s.trim())
425            .filter(|s| !s.is_empty())
426            .map(str::to_string)
427            .collect();
428        body.watchers = Some(watchers);
429    }
430
431    if Confirm::new()
432        .with_prompt("Set a new TTL (seconds)?")
433        .default(false)
434        .interact()
435        .map_err(err)?
436    {
437        let ttl: u32 = Input::new()
438            .with_prompt("TTL (seconds)")
439            .interact_text()
440            .map_err(err)?;
441        body.ttl = Some(ttl);
442    }
443
444    if Confirm::new()
445        .with_prompt("Add an audit label for this update?")
446        .default(true)
447        .interact()
448        .map_err(err)?
449    {
450        let label: String = Input::new()
451            .with_prompt("Audit label")
452            .allow_empty(true)
453            .interact_text()
454            .map_err(err)?;
455        if !label.trim().is_empty() {
456            body.label = Some(label.trim().to_string());
457        }
458    }
459
460    Ok(body)
461}
462
463/// Final confirmation before publishing. Shows a one-line summary
464/// of what the body actually contains; operator hits Enter (default
465/// `false`) to abort. Returns `Err(PublishCancelled)` on `false`.
466pub fn confirm_publish(body: &UpdateDidWebvhBody, no_confirm: bool) -> Result<(), EditFlowError> {
467    if no_confirm {
468        return Ok(());
469    }
470
471    let mut summary = Vec::<&str>::new();
472    if body.document.is_some() {
473        summary.push("document");
474    }
475    if body.pre_rotation_count.is_some() {
476        summary.push("pre-rotation");
477    }
478    if body.watchers.is_some() {
479        summary.push("watchers");
480    }
481    if body.witnesses.is_some() {
482        summary.push("witnesses");
483    }
484    if body.ttl.is_some() {
485        summary.push("ttl");
486    }
487    if body.label.is_some() {
488        summary.push("label");
489    }
490    let summary = if summary.is_empty() {
491        "(nothing — body is empty)".to_string()
492    } else {
493        summary.join(", ")
494    };
495
496    let go = dialoguer::Confirm::new()
497        .with_prompt(format!(
498            "Publish a new LogEntry with these changes ({summary})?"
499        ))
500        .default(false)
501        .interact()
502        .map_err(|e| EditFlowError::Prompt(e.to_string()))?;
503    if !go {
504        return Err(EditFlowError::PublishCancelled);
505    }
506    Ok(())
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use serde_json::json;
513
514    #[test]
515    fn extract_current_document_returns_state_of_last_line() {
516        let log = "{\"versionId\":\"1\",\"state\":{\"id\":\"did:webvh:foo\"}}\n\
517                   {\"versionId\":\"2\",\"state\":{\"id\":\"did:webvh:foo\",\"key\":\"v2\"}}\n";
518        let doc = extract_current_document(log).unwrap();
519        assert_eq!(doc["id"], "did:webvh:foo");
520        assert_eq!(doc["key"], "v2");
521    }
522
523    #[test]
524    fn extract_latest_version_id_returns_last_entrys_version_id() {
525        let log = "{\"versionId\":\"1-aaa\",\"state\":{\"id\":\"did:webvh:foo\"}}\n\
526                   {\"versionId\":\"2-bbb\",\"state\":{\"id\":\"did:webvh:foo\"}}\n";
527        assert_eq!(extract_latest_version_id(log).unwrap(), "2-bbb");
528    }
529
530    #[test]
531    fn extract_latest_version_id_skips_trailing_blank_lines() {
532        let log = "{\"versionId\":\"1-aaa\",\"state\":{\"id\":\"x\"}}\n\
533                   {\"versionId\":\"2-bbb\",\"state\":{\"id\":\"x\"}}\n\n\n";
534        assert_eq!(extract_latest_version_id(log).unwrap(), "2-bbb");
535    }
536
537    #[test]
538    fn extract_latest_version_id_errors_on_empty_log() {
539        let err = extract_latest_version_id("").unwrap_err();
540        assert!(matches!(err, EditFlowError::EmptyLog), "got {err:?}");
541    }
542
543    #[test]
544    fn extract_pre_rotation_status_walks_back_through_deltas() {
545        // Latest entry omits parameters → walk back to entry 2 which
546        // committed 2 next-key hashes.
547        let log = "{\"versionId\":\"1-aaa\",\"parameters\":{\"nextKeyHashes\":[\"Qm1\"]}}\n\
548                   {\"versionId\":\"2-bbb\",\"parameters\":{\"nextKeyHashes\":[\"Qm2a\",\"Qm2b\"]}}\n\
549                   {\"versionId\":\"3-ccc\",\"parameters\":{}}\n";
550        let s = extract_pre_rotation_status(log);
551        assert!(s.active);
552        assert_eq!(s.committed_count, 2);
553        assert!(!s.never_set);
554    }
555
556    #[test]
557    fn extract_pre_rotation_status_recognises_explicit_disable() {
558        // Entry 2 explicitly empties nextKeyHashes → pre-rotation off.
559        let log = "{\"versionId\":\"1-aaa\",\"parameters\":{\"nextKeyHashes\":[\"Qm1\"]}}\n\
560                   {\"versionId\":\"2-bbb\",\"parameters\":{\"nextKeyHashes\":[]}}\n";
561        let s = extract_pre_rotation_status(log);
562        assert!(!s.active);
563        assert_eq!(s.committed_count, 0);
564        assert!(!s.never_set);
565    }
566
567    #[test]
568    fn extract_pre_rotation_status_returns_never_set_when_no_entry_mentions_it() {
569        let log = "{\"versionId\":\"1-aaa\",\"parameters\":{}}\n\
570                   {\"versionId\":\"2-bbb\",\"parameters\":{}}\n";
571        let s = extract_pre_rotation_status(log);
572        assert!(!s.active);
573        assert_eq!(s.committed_count, 0);
574        assert!(
575            s.never_set,
576            "no nextKeyHashes anywhere → never_set must be true"
577        );
578    }
579
580    #[test]
581    fn extract_current_document_skips_trailing_blank_lines() {
582        let log = "{\"versionId\":\"1\",\"state\":{\"id\":\"did:webvh:foo\"}}\n\n\n";
583        let doc = extract_current_document(log).unwrap();
584        assert_eq!(doc["id"], "did:webvh:foo");
585    }
586
587    #[test]
588    fn extract_current_document_rejects_empty_log() {
589        let err = extract_current_document("").unwrap_err();
590        assert!(matches!(err, EditFlowError::EmptyLog));
591    }
592
593    #[test]
594    fn extract_current_document_rejects_unparseable_line() {
595        let err = extract_current_document("not json").unwrap_err();
596        assert!(matches!(err, EditFlowError::LogParse(_)));
597    }
598
599    #[test]
600    fn assert_did_id_unchanged_passes_when_id_matches() {
601        let prior = json!({"id": "did:webvh:foo", "x": 1});
602        let edited = json!({"id": "did:webvh:foo", "x": 2});
603        assert!(assert_did_id_unchanged(&prior, &edited).is_ok());
604    }
605
606    #[test]
607    fn assert_did_id_unchanged_rejects_id_mutation() {
608        let prior = json!({"id": "did:webvh:foo"});
609        let edited = json!({"id": "did:webvh:bar"});
610        let err = assert_did_id_unchanged(&prior, &edited).unwrap_err();
611        match err {
612            EditFlowError::DidIdChanged { prior, edited } => {
613                assert_eq!(prior, "did:webvh:foo");
614                assert_eq!(edited, "did:webvh:bar");
615            }
616            other => panic!("expected DidIdChanged, got {other:?}"),
617        }
618    }
619
620    #[test]
621    fn diff_summary_describes_added_changed_removed() {
622        let prior = json!({"id": "did:webvh:foo", "service": [], "kept": "v1"});
623        let edited = json!({"id": "did:webvh:foo", "service": [{}], "newField": 1});
624        let summary = diff_summary(&prior, &edited);
625        assert!(summary.contains("added: newField"), "got: {summary}");
626        assert!(summary.contains("changed: service"), "got: {summary}");
627        assert!(summary.contains("removed: kept"), "got: {summary}");
628    }
629
630    #[test]
631    fn diff_summary_handles_no_changes() {
632        let doc = json!({"id": "did:webvh:foo"});
633        let summary = diff_summary(&doc, &doc);
634        assert!(summary.contains("no top-level fields changed"));
635    }
636
637    #[test]
638    fn build_options_from_flags_no_flags_produces_empty_body() {
639        let flags = EditFlags::default();
640        let body = build_options_from_flags(&flags).unwrap();
641        assert!(body.document.is_none());
642        assert!(body.pre_rotation_count.is_none());
643        assert!(body.watchers.is_none());
644        assert!(body.ttl.is_none());
645        assert!(body.label.is_none());
646    }
647
648    #[test]
649    fn build_options_from_flags_no_watchers_clears_set() {
650        let flags = EditFlags {
651            no_watchers: true,
652            ..Default::default()
653        };
654        let body = build_options_from_flags(&flags).unwrap();
655        assert_eq!(body.watchers, Some(Vec::<String>::new()));
656    }
657
658    #[test]
659    fn build_options_from_flags_watchers_replace_set() {
660        let flags = EditFlags {
661            watchers: vec!["https://w1.example".into(), "https://w2.example".into()],
662            ..Default::default()
663        };
664        let body = build_options_from_flags(&flags).unwrap();
665        assert_eq!(
666            body.watchers,
667            Some(vec![
668                "https://w1.example".to_string(),
669                "https://w2.example".to_string(),
670            ])
671        );
672    }
673
674    #[test]
675    fn build_options_from_flags_propagates_pre_rotation_ttl_label() {
676        let flags = EditFlags {
677            pre_rotation: Some(3),
678            ttl: Some(86_400),
679            label: Some("audit".into()),
680            ..Default::default()
681        };
682        let body = build_options_from_flags(&flags).unwrap();
683        assert_eq!(body.pre_rotation_count, Some(3));
684        assert_eq!(body.ttl, Some(86_400));
685        assert_eq!(body.label.as_deref(), Some("audit"));
686    }
687
688    #[test]
689    fn build_options_from_flags_loads_document_from_file() {
690        // tempfile is already a workspace-wide test dep but not in
691        // vta-cli-common's dev-deps; use std::env::temp_dir +
692        // pid-suffix instead so the test stays self-contained.
693        let dir = std::env::temp_dir();
694        let path = dir.join(format!(
695            "vta-cli-common-edit-flags-{}.json",
696            std::process::id()
697        ));
698        std::fs::write(&path, r#"{"id":"did:webvh:foo","verificationMethod":[]}"#).unwrap();
699        let flags = EditFlags {
700            document_file: Some(path.clone()),
701            ..Default::default()
702        };
703        let body = build_options_from_flags(&flags).unwrap();
704        assert_eq!(body.document.as_ref().unwrap()["id"], "did:webvh:foo");
705        let _ = std::fs::remove_file(&path);
706    }
707}