1use serde_json::Value;
24
25use vta_sdk::protocols::did_management::update::UpdateDidWebvhBody;
26
27#[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
72pub 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
93pub 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#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct PreRotationStatus {
126 pub committed_count: usize,
130 pub active: bool,
135 pub never_set: bool,
139}
140
141pub 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
174pub 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
182pub 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
196pub 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 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
226pub 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#[derive(Debug, Clone, Default)]
275pub struct EditFlags {
276 pub document_file: Option<std::path::PathBuf>,
279 pub options_file: Option<std::path::PathBuf>,
284 pub pre_rotation: Option<u32>,
285 pub ttl: Option<u32>,
286 pub watchers: Vec<String>,
289 pub no_watchers: bool,
291 pub label: Option<String>,
292}
293
294pub 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 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 expected_version_id: None,
349 })
350}
351
352pub 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 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
464pub 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 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 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 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}