Skip to main content

vta_cli_common/commands/
collection.rs

1//! `pnm collection …` — which party collects a persona DID's messages at its mediator
2//! (`vta/collection/{claim,release,list}/0.1`, VTI-NTF-080 – 093).
3//!
4//! The mediator permits one connection per DID, so the VTA and a client agree which of them
5//! holds it: the VTA collects for every persona no client has claimed (when its operator has
6//! enabled collection by default), and a client that holds the connection itself claims the
7//! persona first, under a lease. These commands make, renew, release and list those claims.
8
9use serde_json::{Value, json};
10use vta_sdk::prelude::*;
11
12use crate::render::{BOLD, CYAN, DIM, GREEN, RESET, YELLOW, is_json_output, print_json};
13
14/// Options for `collection list`.
15#[derive(Debug, Clone, Default)]
16pub struct ListOptions {
17    pub identifier: Option<String>,
18    pub collector: Option<String>,
19    pub limit: Option<u32>,
20    pub cursor: Option<String>,
21}
22
23/// Whether `e` is the declared refusal `local` (`claimHeld`, `notCollectable`, …) of any
24/// `vta/collection/*` task.
25fn refused_as(e: &VtaError, local: &str) -> bool {
26    e.rejection_code()
27        .is_some_and(|c| c == local || c.ends_with(&format!(":{local}")))
28}
29
30/// The refusal, with the command the operator most likely wants next.
31fn explain(
32    e: VtaError,
33    context: &str,
34    identifier: &str,
35    claiming: bool,
36) -> Box<dyn std::error::Error> {
37    let hint = if refused_as(&e, "claimHeld") {
38        format!(
39            "Another client holds a collection claim for {identifier}. Release it — it then \
40             ends at its stated end, because that client may still be connected — and claim \
41             again once it has ended:\n  pnm collection release --context {context} \
42             --identifier {identifier}\n  pnm collection list --context {context} --identifier \
43             {identifier}"
44        )
45    } else if refused_as(&e, "claimNotRenewable") {
46        format!(
47            "Your claim for {identifier} was released by another party and cannot be renewed. \
48             Stop collecting before its end (`pnm collection list --context {context} \
49             --identifier {identifier}` shows it), and claim again once it has ended."
50        )
51    } else if refused_as(&e, "notCollectable") {
52        format!(
53            "{identifier} is not an identifier this VTA collects for in context '{context}': it \
54             holds no messaging keys for it there, its DID document advertises no mediated \
55             transport, or it is the VTA's own identity, another node's or one provisioned for \
56             another party, which its holder collects for. `pnm collection list --context \
57             {context}` shows the identifiers it does.{}",
58            if claiming {
59                ""
60            } else {
61                " There is no claim to release."
62            }
63        )
64    } else {
65        return Box::new(e);
66    };
67    format!("{e}\n\n{hint}").into()
68}
69
70/// `claim` → `vta/collection/claim/0.1`.
71pub async fn cmd_collection_claim(
72    client: &VtaClient,
73    context: &str,
74    identifier: &str,
75    duration_seconds: Option<i64>,
76) -> Result<(), Box<dyn std::error::Error>> {
77    let mut req = json!({ "contextId": context, "identifier": identifier });
78    if let Some(d) = duration_seconds {
79        req["durationSeconds"] = json!(d);
80    }
81    let resp = client
82        .collection_claim_json(req)
83        .await
84        .map_err(|e| explain(e, context, identifier, true))?;
85    if is_json_output() {
86        print_json(&resp)?;
87        return Ok(());
88    }
89    let verb = if resp["renewed"].as_bool() == Some(true) {
90        "Renewed"
91    } else {
92        "Claimed"
93    };
94    println!(
95        "{GREEN}\u{2713}{RESET} {verb} {BOLD}{identifier}{RESET} until {}. The VTA has stopped \
96         collecting for it; stop collecting before that end unless you renew.",
97        resp["endsAt"].as_str().unwrap_or("?")
98    );
99    Ok(())
100}
101
102/// `release` → `vta/collection/release/0.1`.
103pub async fn cmd_collection_release(
104    client: &VtaClient,
105    context: &str,
106    identifier: &str,
107) -> Result<(), Box<dyn std::error::Error>> {
108    let resp = client
109        .collection_release_json(json!({ "contextId": context, "identifier": identifier }))
110        .await
111        .map_err(|e| explain(e, context, identifier, false))?;
112    if is_json_output() {
113        print_json(&resp)?;
114        return Ok(());
115    }
116    match resp["endsAt"].as_str() {
117        Some(at) => println!(
118            "{GREEN}\u{2713}{RESET} Released the claim for {BOLD}{identifier}{RESET}; it ends \
119             at {at}."
120        ),
121        None => println!("No claim for {BOLD}{identifier}{RESET} was in force; nothing changed."),
122    }
123    Ok(())
124}
125
126fn line(item: &Value) -> String {
127    let id = item["identifier"].as_str().unwrap_or("?");
128    match item["collector"].as_str() {
129        Some("client") => {
130            let c = &item["claim"];
131            format!(
132                "{CYAN}{id}{RESET}  {BOLD}client{RESET} {}  {DIM}until {}{}{RESET}",
133                c["claimant"].as_str().unwrap_or("?"),
134                c["endsAt"].as_str().unwrap_or("?"),
135                if c["renewable"].as_bool() == Some(false) {
136                    ", not renewable"
137                } else {
138                    ""
139                }
140            )
141        }
142        Some("none") => format!(
143            "{CYAN}{id}{RESET}  {YELLOW}nobody{RESET}  {DIM}({}){RESET}",
144            match item["notCollectedReason"].as_str() {
145                Some("disabled") => "collection by default is not enabled on this VTA",
146                Some("limit") => "the VTA is at its bound on collected identifiers",
147                Some("conflict") =>
148                    "another connection kept evicting the VTA's; claim it from that client",
149                Some(other) => other,
150                None => "?",
151            }
152        ),
153        Some(c) => format!("{CYAN}{id}{RESET}  {BOLD}{c}{RESET}"),
154        None => format!("{CYAN}{id}{RESET}  ?"),
155    }
156}
157
158/// `list` → `vta/collection/list/0.1`.
159pub async fn cmd_collection_list(
160    client: &VtaClient,
161    context: &str,
162    opts: &ListOptions,
163) -> Result<(), Box<dyn std::error::Error>> {
164    let mut req = json!({ "contextId": context });
165    if let Some(i) = &opts.identifier {
166        req["identifier"] = json!(i);
167    }
168    if let Some(c) = &opts.collector {
169        req["collector"] = json!(c);
170    }
171    if let Some(l) = opts.limit {
172        req["limit"] = json!(l);
173    }
174    if let Some(c) = &opts.cursor {
175        req["cursor"] = json!(c);
176    }
177    let resp = client.collection_list_json(req).await?;
178    if is_json_output() {
179        print_json(&resp)?;
180        return Ok(());
181    }
182    let items = resp["items"].as_array().cloned().unwrap_or_default();
183    if items.is_empty() {
184        println!("No identifiers in context '{context}' that this VTA would collect for.");
185    } else {
186        println!();
187        println!("{BOLD}Collection in context '{context}'{RESET}");
188        println!();
189        for item in &items {
190            println!("  {}", line(item));
191        }
192        println!();
193    }
194    if let Some(c) = resp["nextCursor"].as_str() {
195        println!("{DIM}More remain: pass --cursor {c}{RESET}");
196    }
197    Ok(())
198}