1use serde_json::Value;
24
25use vta_sdk::protocols::did_management::update::UpdateDidWebvhBody;
26
27#[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
71pub 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
92pub 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#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct PreRotationStatus {
125 pub committed_count: usize,
129 pub active: bool,
134 pub never_set: bool,
138}
139
140pub 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
173pub 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
181pub 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
195pub 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 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
225pub 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#[derive(Debug, Clone, Default)]
274pub struct EditFlags {
275 pub document_file: Option<std::path::PathBuf>,
278 pub options_file: Option<std::path::PathBuf>,
283 pub pre_rotation: Option<u32>,
284 pub ttl: Option<u32>,
285 pub watchers: Vec<String>,
288 pub no_watchers: bool,
290 pub label: Option<String>,
291}
292
293pub 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 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 expected_version_id: None,
348 })
349}
350
351pub 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 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
463pub 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 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 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 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}