Skip to main content

solid_pod_rs_server/cli/
mod.rs

1//! Operator CLI subcommands — Sprint 11 rows 138, 163, 168.
2//!
3//! Three thin wrappers over library primitives:
4//!
5//! | Subcommand                           | JSS ref                | Primitive                          |
6//! |--------------------------------------|------------------------|------------------------------------|
7//! | `quota reconcile <pod>` / `--all`    | `bin/jss.js quota reconcile` | [`solid_pod_rs::quota::FsQuotaStore::reconcile`] |
8//! | `account delete <user-id>`           | JSS #292 (`d9e56d8`)   | [`solid_pod_rs_idp::UserStore::delete`] |
9//! | `invite create -u N [--expires-in]`  | JSS #304 (`6578ab9`)   | [`solid_pod_rs_idp::InviteStore`]  |
10//!
11//! Every runner is async and takes an already-constructed store so
12//! the test harness can drive them with an in-memory double rather
13//! than a real filesystem / database.
14
15use clap::{Args, Subcommand};
16
17// ---------------------------------------------------------------------------
18// Public CLI surface
19// ---------------------------------------------------------------------------
20
21/// Operator subcommands. The binary entry point in `main.rs` parses
22/// this via `#[command(subcommand)]` alongside the existing
23/// server-run flags.
24#[derive(Debug, Subcommand)]
25pub enum OperatorCommand {
26    /// Quota operations — currently only `reconcile`.
27    #[command(subcommand)]
28    Quota(QuotaCommand),
29
30    /// Account lifecycle — currently only `delete`.
31    #[command(subcommand)]
32    Account(AccountCommand),
33
34    /// Invite-token operations — currently only `create`.
35    #[command(subcommand)]
36    Invite(InviteCommand),
37}
38
39// ---------------------------------------------------------------------------
40// `quota` — row 138
41// ---------------------------------------------------------------------------
42
43/// `quota` subcommands.
44#[derive(Debug, Subcommand)]
45pub enum QuotaCommand {
46    /// Walk the pod's storage tree, recompute used bytes, rewrite the
47    /// `.quota.json` sidecar. Pass `--all` to reconcile every pod
48    /// directly under `--root`.
49    Reconcile(QuotaReconcileArgs),
50}
51
52/// Arguments for `quota reconcile`.
53#[derive(Debug, Args, Clone)]
54pub struct QuotaReconcileArgs {
55    /// Pod directory name under `--root`. Mutually exclusive with
56    /// `--all`; clap enforces the requirement at parse time.
57    #[arg(required_unless_present = "all")]
58    pub pod_id: Option<String>,
59
60    /// Reconcile every immediate subdirectory of `--root`.
61    #[arg(long, conflicts_with = "pod_id")]
62    pub all: bool,
63
64    /// Filesystem root containing pod directories. Falls back to the
65    /// `JSS_STORAGE_ROOT` env var, then `./data`.
66    #[arg(long, env = "JSS_STORAGE_ROOT", default_value = "./data")]
67    pub root: std::path::PathBuf,
68
69    /// Default quota cap applied when a sidecar is absent or has
70    /// `limit_bytes == 0`. Bytes. `0` means "no cap".
71    #[arg(long, default_value_t = 0)]
72    pub default_limit: u64,
73}
74
75// ---------------------------------------------------------------------------
76// `account delete` — row 168
77// ---------------------------------------------------------------------------
78
79/// `account` subcommands.
80#[derive(Debug, Subcommand)]
81pub enum AccountCommand {
82    /// Remove a user, their pods, and their WebID profile.
83    Delete(AccountDeleteArgs),
84}
85
86/// Arguments for `account delete`.
87#[derive(Debug, Args, Clone)]
88pub struct AccountDeleteArgs {
89    /// Stable internal user id (the one stored on the [`User`] row).
90    pub user_id: String,
91
92    /// Skip the interactive confirmation prompt.
93    #[arg(long)]
94    pub yes: bool,
95}
96
97// ---------------------------------------------------------------------------
98// `invite create` — row 163
99// ---------------------------------------------------------------------------
100
101/// `invite` subcommands.
102#[derive(Debug, Subcommand)]
103pub enum InviteCommand {
104    /// Mint an opaque invite token, store it, print the invite URL.
105    Create(InviteCreateArgs),
106}
107
108/// Arguments for `invite create`.
109#[derive(Debug, Args, Clone)]
110pub struct InviteCreateArgs {
111    /// Maximum redemptions. Omit for unlimited uses.
112    #[arg(short = 'u', long = "uses")]
113    pub uses: Option<u32>,
114
115    /// Optional expiry, as `30s` / `5m` / `2h` / `7d` / `1w`, or a
116    /// bare integer (seconds).
117    #[arg(long = "expires-in")]
118    pub expires_in: Option<String>,
119
120    /// Base URL used to build the final invite URL. Defaults to an
121    /// operator-conventional `https://pod.invalid` so the CLI is
122    /// usable in non-interactive pipelines; production deployments
123    /// override this with their public origin.
124    #[arg(long = "base-url", default_value = "https://pod.invalid")]
125    pub base_url: String,
126}
127
128// ---------------------------------------------------------------------------
129// Runners — pure library surface, unit-testable without a process
130// ---------------------------------------------------------------------------
131
132/// Reconcile result — returned by the quota runner so integration
133/// tests can assert on post-state rather than parsing stdout.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ReconcileOutcome {
136    /// Pod dir name.
137    pub pod: String,
138    /// Recomputed used bytes.
139    pub used_bytes: u64,
140    /// Effective limit (may be the default-limit fallback).
141    pub limit_bytes: u64,
142}
143
144/// Run `quota reconcile`. Returns one outcome per reconciled pod.
145///
146/// Requires the `quota` Cargo feature on `solid-pod-rs` to be active
147/// transitively — this function is only compiled when the `quota`
148/// feature is on at this crate's level too (see `[features]` in
149/// `Cargo.toml`). Without that feature the binary falls back to
150/// the lower-case message runner below.
151#[cfg(feature = "quota")]
152pub async fn run_quota_reconcile(
153    args: &QuotaReconcileArgs,
154) -> anyhow::Result<Vec<ReconcileOutcome>> {
155    use solid_pod_rs::quota::{FsQuotaStore, QuotaPolicy};
156
157    // Resolve the pod set. `--all` iterates immediate subdirs of
158    // `--root`; otherwise we use the single positional pod id.
159    let pods: Vec<String> = if args.all {
160        let mut out = Vec::new();
161        let mut rd = match tokio::fs::read_dir(&args.root).await {
162            Ok(r) => r,
163            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
164                anyhow::bail!("storage root does not exist: {}", args.root.display());
165            }
166            Err(e) => return Err(e.into()),
167        };
168        while let Some(entry) = rd.next_entry().await? {
169            if entry.file_type().await?.is_dir() {
170                if let Some(name) = entry.file_name().to_str() {
171                    // Skip hidden dotfiles (`.git`, `.cache`, etc).
172                    if !name.starts_with('.') {
173                        out.push(name.to_string());
174                    }
175                }
176            }
177        }
178        out.sort();
179        out
180    } else {
181        vec![args
182            .pod_id
183            .clone()
184            .expect("clap guarantees pod_id or --all")]
185    };
186
187    let store = FsQuotaStore::new(args.root.clone(), args.default_limit);
188    let mut outcomes = Vec::with_capacity(pods.len());
189    for pod in pods {
190        let usage = store
191            .reconcile(&pod)
192            .await
193            .map_err(|e| anyhow::anyhow!("reconcile {pod}: {e}"))?;
194        outcomes.push(ReconcileOutcome {
195            pod,
196            used_bytes: usage.used_bytes,
197            limit_bytes: usage.limit_bytes,
198        });
199    }
200    Ok(outcomes)
201}
202
203/// Fallback runner compiled when the `quota` feature is off at this
204/// crate's level. Surfaces the actionable error rather than a cryptic
205/// `command not found`.
206#[cfg(not(feature = "quota"))]
207pub async fn run_quota_reconcile(
208    _args: &QuotaReconcileArgs,
209) -> anyhow::Result<Vec<ReconcileOutcome>> {
210    anyhow::bail!(
211        "`quota reconcile` requires the `quota` cargo feature. Rebuild with \
212         `--features solid-pod-rs-server/quota`."
213    )
214}
215
216/// Input reader trait so tests can feed confirmation text without
217/// touching real stdin.
218pub trait Prompt: Send {
219    /// Emit a prompt line and read the user's answer. Returns
220    /// `Ok(None)` when stdin is closed (EOF).
221    fn ask(&mut self, prompt: &str) -> std::io::Result<Option<String>>;
222}
223
224/// Stdin-backed [`Prompt`] used by the real binary.
225pub struct StdinPrompt;
226
227impl Prompt for StdinPrompt {
228    fn ask(&mut self, prompt: &str) -> std::io::Result<Option<String>> {
229        use std::io::{BufRead, Write};
230        let stderr = std::io::stderr();
231        let mut handle = stderr.lock();
232        write!(handle, "{prompt}")?;
233        handle.flush()?;
234        let stdin = std::io::stdin();
235        let mut line = String::new();
236        match stdin.lock().read_line(&mut line) {
237            Ok(0) => Ok(None),
238            Ok(_) => Ok(Some(line.trim_end_matches(['\r', '\n']).to_string())),
239            Err(e) => Err(e),
240        }
241    }
242}
243
244/// Run `account delete`. Returns `Ok(true)` when a row was actually
245/// removed, `Ok(false)` when the user id was unknown, and `Err(..)`
246/// when the caller skipped confirmation without `--yes`.
247pub async fn run_account_delete<S, P>(
248    args: &AccountDeleteArgs,
249    store: &S,
250    prompt: &mut P,
251) -> anyhow::Result<bool>
252where
253    S: solid_pod_rs_idp::UserStore + ?Sized,
254    P: Prompt,
255{
256    if !args.yes {
257        let banner = format!(
258            "About to delete user {user} and every associated pod + WebID profile.\n\
259             Type the user id to confirm: ",
260            user = args.user_id
261        );
262        let answer = prompt
263            .ask(&banner)?
264            .ok_or_else(|| anyhow::anyhow!("account delete aborted: stdin closed without --yes"))?;
265        if answer.trim() != args.user_id {
266            anyhow::bail!(
267                "account delete aborted: confirmation {answer:?} did not match {user:?}",
268                user = args.user_id
269            );
270        }
271    }
272    let deleted = store
273        .delete(&args.user_id)
274        .await
275        .map_err(|e| anyhow::anyhow!("user store delete: {e}"))?;
276    Ok(deleted)
277}
278
279/// Run `invite create`. Returns the minted invite *and* the final
280/// invite URL so the binary can print both in one step and tests can
281/// assert on the URL's shape.
282pub async fn run_invite_create<S>(
283    args: &InviteCreateArgs,
284    store: &S,
285) -> anyhow::Result<(solid_pod_rs_idp::Invite, String)>
286where
287    S: solid_pod_rs_idp::InviteStore + ?Sized,
288{
289    let expires_at = match args.expires_in.as_deref() {
290        Some(spec) => {
291            let dur = solid_pod_rs_idp::parse_invite_duration(spec)
292                .map_err(|e| anyhow::anyhow!("--expires-in {spec:?}: {e}"))?;
293            let chrono_dur = chrono::Duration::from_std(dur)
294                .map_err(|e| anyhow::anyhow!("--expires-in {spec:?} out of range: {e}"))?;
295            Some(chrono::Utc::now() + chrono_dur)
296        }
297        None => None,
298    };
299    let token = solid_pod_rs_idp::mint_invite_token();
300    let invite = solid_pod_rs_idp::Invite {
301        token: token.clone(),
302        max_uses: args.uses,
303        expires_at,
304    };
305    store
306        .insert(invite.clone())
307        .await
308        .map_err(|e| anyhow::anyhow!("invite store insert: {e}"))?;
309    let base = args.base_url.trim_end_matches('/');
310    let url = format!("{base}/invite?token={token}");
311    Ok((invite, url))
312}
313
314// ---------------------------------------------------------------------------
315// Binary-layer glue
316// ---------------------------------------------------------------------------
317
318/// Dispatch an [`OperatorCommand`] against real stores. The binary in
319/// `main.rs` calls this, tests skip it and drive the `run_*`
320/// functions directly.
321pub async fn dispatch(cmd: OperatorCommand) -> anyhow::Result<()> {
322    match cmd {
323        OperatorCommand::Quota(QuotaCommand::Reconcile(args)) => {
324            let outcomes = run_quota_reconcile(&args).await?;
325            for out in &outcomes {
326                println!(
327                    "reconciled pod={} used_bytes={} limit_bytes={}",
328                    out.pod, out.used_bytes, out.limit_bytes
329                );
330            }
331            if outcomes.is_empty() {
332                println!("no pods found under {}", args.root.display());
333            }
334            Ok(())
335        }
336        OperatorCommand::Account(AccountCommand::Delete(args)) => {
337            // The binary ships with the in-memory UserStore by default;
338            // operators wiring a persistent store plug their own
339            // dispatch in place of this one. The default keeps the
340            // subcommand callable in dev without a configured DB.
341            let store = solid_pod_rs_idp::InMemoryUserStore::new();
342            let mut prompt = StdinPrompt;
343            let deleted = run_account_delete(&args, &store, &mut prompt).await?;
344            if deleted {
345                println!("deleted user {}", args.user_id);
346            } else {
347                println!("no such user {}", args.user_id);
348            }
349            Ok(())
350        }
351        OperatorCommand::Invite(InviteCommand::Create(args)) => {
352            let store = solid_pod_rs_idp::InMemoryInviteStore::new();
353            let (invite, url) = run_invite_create(&args, &store).await?;
354            println!("token: {}", invite.token);
355            match invite.max_uses {
356                Some(n) => println!("max_uses: {n}"),
357                None => println!("max_uses: unlimited"),
358            }
359            match invite.expires_at {
360                Some(t) => println!("expires_at: {}", t.to_rfc3339()),
361                None => println!("expires_at: never"),
362            }
363            println!("url: {url}");
364            Ok(())
365        }
366    }
367}