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