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        // `pnm did-mgmt dids scid-alias` sets this; the edit flow never does.
350        scid_also_known_as: None,
351    })
352}
353
354/// Walk an interactive `Confirm`/`Input` chain asking the operator
355/// about the webvh parameters they want to change. `edited_doc` is
356/// the post-editor DID document (or `None` if the operator
357/// declined to edit). Returns the assembled
358/// [`UpdateDidWebvhBody`].
359///
360/// The chain is opt-in for every field: each starts with a
361/// `Confirm` defaulting to `false` so the operator can hit Enter
362/// repeatedly to skip everything and just publish the document
363/// edit on its own.
364pub fn prompt_webvh_params(
365    edited_doc: Option<Value>,
366    pre_rotation_status: Option<&PreRotationStatus>,
367) -> Result<UpdateDidWebvhBody, EditFlowError> {
368    use dialoguer::{Confirm, Input};
369
370    fn err(e: dialoguer::Error) -> EditFlowError {
371        EditFlowError::Prompt(e.to_string())
372    }
373
374    let mut body = UpdateDidWebvhBody {
375        document: edited_doc,
376        ..Default::default()
377    };
378
379    // Show the current pre-rotation setup so the operator can decide
380    // what (if anything) to change. Skipped when the caller didn't
381    // supply a status (e.g. an offline test path that has no log).
382    if let Some(s) = pre_rotation_status {
383        if s.never_set {
384            eprintln!("  Pre-rotation: disabled (never enabled on this DID).");
385        } else if !s.active {
386            eprintln!("  Pre-rotation: disabled (explicitly turned off).");
387        } else {
388            let plural = if s.committed_count == 1 {
389                "key"
390            } else {
391                "keys"
392            };
393            eprintln!(
394                "  Pre-rotation: active — {} {plural} currently committed.",
395                s.committed_count
396            );
397        }
398    }
399
400    if Confirm::new()
401        .with_prompt("Override pre-rotation count?")
402        .default(false)
403        .interact()
404        .map_err(err)?
405    {
406        let n: u32 = Input::new()
407            .with_prompt("New pre-rotation count (0 disables)")
408            .default(0)
409            .interact_text()
410            .map_err(err)?;
411        body.pre_rotation_count = Some(n);
412    }
413
414    if Confirm::new()
415        .with_prompt("Replace watcher URLs?")
416        .default(false)
417        .interact()
418        .map_err(err)?
419    {
420        let raw: String = Input::new()
421            .with_prompt("Comma-separated watcher URLs (empty input disables watchers entirely)")
422            .allow_empty(true)
423            .interact_text()
424            .map_err(err)?;
425        let watchers: Vec<String> = raw
426            .split(',')
427            .map(|s| s.trim())
428            .filter(|s| !s.is_empty())
429            .map(str::to_string)
430            .collect();
431        body.watchers = Some(watchers);
432    }
433
434    if Confirm::new()
435        .with_prompt("Set a new TTL (seconds)?")
436        .default(false)
437        .interact()
438        .map_err(err)?
439    {
440        let ttl: u32 = Input::new()
441            .with_prompt("TTL (seconds)")
442            .interact_text()
443            .map_err(err)?;
444        body.ttl = Some(ttl);
445    }
446
447    if Confirm::new()
448        .with_prompt("Add an audit label for this update?")
449        .default(true)
450        .interact()
451        .map_err(err)?
452    {
453        let label: String = Input::new()
454            .with_prompt("Audit label")
455            .allow_empty(true)
456            .interact_text()
457            .map_err(err)?;
458        if !label.trim().is_empty() {
459            body.label = Some(label.trim().to_string());
460        }
461    }
462
463    Ok(body)
464}
465
466/// Final confirmation before publishing. Shows a one-line summary
467/// of what the body actually contains; operator hits Enter (default
468/// `false`) to abort. Returns `Err(PublishCancelled)` on `false`.
469pub fn confirm_publish(body: &UpdateDidWebvhBody, no_confirm: bool) -> Result<(), EditFlowError> {
470    if no_confirm {
471        return Ok(());
472    }
473
474    let mut summary = Vec::<&str>::new();
475    if body.document.is_some() {
476        summary.push("document");
477    }
478    if body.pre_rotation_count.is_some() {
479        summary.push("pre-rotation");
480    }
481    if body.watchers.is_some() {
482        summary.push("watchers");
483    }
484    if body.witnesses.is_some() {
485        summary.push("witnesses");
486    }
487    if body.ttl.is_some() {
488        summary.push("ttl");
489    }
490    if body.label.is_some() {
491        summary.push("label");
492    }
493    let summary = if summary.is_empty() {
494        "(nothing — body is empty)".to_string()
495    } else {
496        summary.join(", ")
497    };
498
499    let go = dialoguer::Confirm::new()
500        .with_prompt(format!(
501            "Publish a new LogEntry with these changes ({summary})?"
502        ))
503        .default(false)
504        .interact()
505        .map_err(|e| EditFlowError::Prompt(e.to_string()))?;
506    if !go {
507        return Err(EditFlowError::PublishCancelled);
508    }
509    Ok(())
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use serde_json::json;
516
517    #[test]
518    fn extract_current_document_returns_state_of_last_line() {
519        let log = "{\"versionId\":\"1\",\"state\":{\"id\":\"did:webvh:foo\"}}\n\
520                   {\"versionId\":\"2\",\"state\":{\"id\":\"did:webvh:foo\",\"key\":\"v2\"}}\n";
521        let doc = extract_current_document(log).unwrap();
522        assert_eq!(doc["id"], "did:webvh:foo");
523        assert_eq!(doc["key"], "v2");
524    }
525
526    #[test]
527    fn extract_latest_version_id_returns_last_entrys_version_id() {
528        let log = "{\"versionId\":\"1-aaa\",\"state\":{\"id\":\"did:webvh:foo\"}}\n\
529                   {\"versionId\":\"2-bbb\",\"state\":{\"id\":\"did:webvh:foo\"}}\n";
530        assert_eq!(extract_latest_version_id(log).unwrap(), "2-bbb");
531    }
532
533    #[test]
534    fn extract_latest_version_id_skips_trailing_blank_lines() {
535        let log = "{\"versionId\":\"1-aaa\",\"state\":{\"id\":\"x\"}}\n\
536                   {\"versionId\":\"2-bbb\",\"state\":{\"id\":\"x\"}}\n\n\n";
537        assert_eq!(extract_latest_version_id(log).unwrap(), "2-bbb");
538    }
539
540    #[test]
541    fn extract_latest_version_id_errors_on_empty_log() {
542        let err = extract_latest_version_id("").unwrap_err();
543        assert!(matches!(err, EditFlowError::EmptyLog), "got {err:?}");
544    }
545
546    #[test]
547    fn extract_pre_rotation_status_walks_back_through_deltas() {
548        // Latest entry omits parameters → walk back to entry 2 which
549        // committed 2 next-key hashes.
550        let log = "{\"versionId\":\"1-aaa\",\"parameters\":{\"nextKeyHashes\":[\"Qm1\"]}}\n\
551                   {\"versionId\":\"2-bbb\",\"parameters\":{\"nextKeyHashes\":[\"Qm2a\",\"Qm2b\"]}}\n\
552                   {\"versionId\":\"3-ccc\",\"parameters\":{}}\n";
553        let s = extract_pre_rotation_status(log);
554        assert!(s.active);
555        assert_eq!(s.committed_count, 2);
556        assert!(!s.never_set);
557    }
558
559    #[test]
560    fn extract_pre_rotation_status_recognises_explicit_disable() {
561        // Entry 2 explicitly empties nextKeyHashes → pre-rotation off.
562        let log = "{\"versionId\":\"1-aaa\",\"parameters\":{\"nextKeyHashes\":[\"Qm1\"]}}\n\
563                   {\"versionId\":\"2-bbb\",\"parameters\":{\"nextKeyHashes\":[]}}\n";
564        let s = extract_pre_rotation_status(log);
565        assert!(!s.active);
566        assert_eq!(s.committed_count, 0);
567        assert!(!s.never_set);
568    }
569
570    #[test]
571    fn extract_pre_rotation_status_returns_never_set_when_no_entry_mentions_it() {
572        let log = "{\"versionId\":\"1-aaa\",\"parameters\":{}}\n\
573                   {\"versionId\":\"2-bbb\",\"parameters\":{}}\n";
574        let s = extract_pre_rotation_status(log);
575        assert!(!s.active);
576        assert_eq!(s.committed_count, 0);
577        assert!(
578            s.never_set,
579            "no nextKeyHashes anywhere → never_set must be true"
580        );
581    }
582
583    #[test]
584    fn extract_current_document_skips_trailing_blank_lines() {
585        let log = "{\"versionId\":\"1\",\"state\":{\"id\":\"did:webvh:foo\"}}\n\n\n";
586        let doc = extract_current_document(log).unwrap();
587        assert_eq!(doc["id"], "did:webvh:foo");
588    }
589
590    #[test]
591    fn extract_current_document_rejects_empty_log() {
592        let err = extract_current_document("").unwrap_err();
593        assert!(matches!(err, EditFlowError::EmptyLog));
594    }
595
596    #[test]
597    fn extract_current_document_rejects_unparseable_line() {
598        let err = extract_current_document("not json").unwrap_err();
599        assert!(matches!(err, EditFlowError::LogParse(_)));
600    }
601
602    #[test]
603    fn assert_did_id_unchanged_passes_when_id_matches() {
604        let prior = json!({"id": "did:webvh:foo", "x": 1});
605        let edited = json!({"id": "did:webvh:foo", "x": 2});
606        assert!(assert_did_id_unchanged(&prior, &edited).is_ok());
607    }
608
609    #[test]
610    fn assert_did_id_unchanged_rejects_id_mutation() {
611        let prior = json!({"id": "did:webvh:foo"});
612        let edited = json!({"id": "did:webvh:bar"});
613        let err = assert_did_id_unchanged(&prior, &edited).unwrap_err();
614        match err {
615            EditFlowError::DidIdChanged { prior, edited } => {
616                assert_eq!(prior, "did:webvh:foo");
617                assert_eq!(edited, "did:webvh:bar");
618            }
619            other => panic!("expected DidIdChanged, got {other:?}"),
620        }
621    }
622
623    #[test]
624    fn diff_summary_describes_added_changed_removed() {
625        let prior = json!({"id": "did:webvh:foo", "service": [], "kept": "v1"});
626        let edited = json!({"id": "did:webvh:foo", "service": [{}], "newField": 1});
627        let summary = diff_summary(&prior, &edited);
628        assert!(summary.contains("added: newField"), "got: {summary}");
629        assert!(summary.contains("changed: service"), "got: {summary}");
630        assert!(summary.contains("removed: kept"), "got: {summary}");
631    }
632
633    #[test]
634    fn diff_summary_handles_no_changes() {
635        let doc = json!({"id": "did:webvh:foo"});
636        let summary = diff_summary(&doc, &doc);
637        assert!(summary.contains("no top-level fields changed"));
638    }
639
640    #[test]
641    fn build_options_from_flags_no_flags_produces_empty_body() {
642        let flags = EditFlags::default();
643        let body = build_options_from_flags(&flags).unwrap();
644        assert!(body.document.is_none());
645        assert!(body.pre_rotation_count.is_none());
646        assert!(body.watchers.is_none());
647        assert!(body.ttl.is_none());
648        assert!(body.label.is_none());
649    }
650
651    #[test]
652    fn build_options_from_flags_no_watchers_clears_set() {
653        let flags = EditFlags {
654            no_watchers: true,
655            ..Default::default()
656        };
657        let body = build_options_from_flags(&flags).unwrap();
658        assert_eq!(body.watchers, Some(Vec::<String>::new()));
659    }
660
661    #[test]
662    fn build_options_from_flags_watchers_replace_set() {
663        let flags = EditFlags {
664            watchers: vec!["https://w1.example".into(), "https://w2.example".into()],
665            ..Default::default()
666        };
667        let body = build_options_from_flags(&flags).unwrap();
668        assert_eq!(
669            body.watchers,
670            Some(vec![
671                "https://w1.example".to_string(),
672                "https://w2.example".to_string(),
673            ])
674        );
675    }
676
677    #[test]
678    fn build_options_from_flags_propagates_pre_rotation_ttl_label() {
679        let flags = EditFlags {
680            pre_rotation: Some(3),
681            ttl: Some(86_400),
682            label: Some("audit".into()),
683            ..Default::default()
684        };
685        let body = build_options_from_flags(&flags).unwrap();
686        assert_eq!(body.pre_rotation_count, Some(3));
687        assert_eq!(body.ttl, Some(86_400));
688        assert_eq!(body.label.as_deref(), Some("audit"));
689    }
690
691    #[test]
692    fn build_options_from_flags_loads_document_from_file() {
693        // tempfile is already a workspace-wide test dep but not in
694        // vta-cli-common's dev-deps; use std::env::temp_dir +
695        // pid-suffix instead so the test stays self-contained.
696        let dir = std::env::temp_dir();
697        let path = dir.join(format!(
698            "vta-cli-common-edit-flags-{}.json",
699            std::process::id()
700        ));
701        std::fs::write(&path, r#"{"id":"did:webvh:foo","verificationMethod":[]}"#).unwrap();
702        let flags = EditFlags {
703            document_file: Some(path.clone()),
704            ..Default::default()
705        };
706        let body = build_options_from_flags(&flags).unwrap();
707        assert_eq!(body.document.as_ref().unwrap()["id"], "did:webvh:foo");
708        let _ = std::fs::remove_file(&path);
709    }
710}