mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
add --json output to the query commands
Scripts and health checks had to scrape formatted text, which changes whenever
the display does.
--json is global and applies to status, ls, pins, sync list, devices list,
transfers, activity, and cache inspect. Action commands keep their human
output; a script that needs to know whether one worked has the exit code.
Two decisions worth recording:
The daemon's own reply is serialized rather than a hand-built parallel
structure, so new fields reach the JSON without anyone remembering to add them
-- but the variant tag is stripped first. Serializing CtlResponse directly
yields {"SyncFolders": {"items": [...]}}, which would make every caller write
jq '.SyncFolders.items[]' and couple it to the name of an internal enum
variant. Callers get {"items": [...]}.
A daemon-side error prints its payload and then fails the command. The first
cut exited 0 after printing the error body, which was caught by running
`pdfs --json ls NoSuchFolder`: a script would have read a failed lookup as
success. The body is still emitted so `kind` is available, but the exit code
is what `set -e` and `if pdfs ...` actually test.
status composes its object by hand instead, being the one query that merges
the local keyring with the daemon -- including the logged-in-but-no-daemon
case, which has no daemon reply to pass through at all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
This commit is contained in:
1 parent
e259016a00
commit
a6a925667a
2 files changed
+170
-23
No files matched your search
@@ -92,6 +92,30 @@ For the local metadata database and content cache:
|
||||
the database while it runs, so it is a deliberate operation rather than
|
||||
something the daemon does on a timer.
|
||||
|
||||
## Scripting (`--json`)
|
||||
|
||||
Query commands accept a global `--json` flag and emit machine-readable output:
|
||||
|
||||
```console
|
||||
$ pdfs --json sync list | jq '.items[] | select(.state != "idle") | .local_path'
|
||||
$ pdfs --json cache inspect | jq '.db_reclaimable_bytes'
|
||||
$ pdfs --json status | jq -r '.mount.mountpoint'
|
||||
```
|
||||
|
||||
Supported on `status`, `ls`, `pins`, `sync list`, `devices list`, `transfers`,
|
||||
`activity`, and `cache inspect`. Commands that perform an action keep their
|
||||
human output — a script that needs to know whether one worked has the exit code.
|
||||
|
||||
Two things worth relying on:
|
||||
|
||||
- **The payload is unwrapped.** Output is `{"items": […]}`, not the daemon's
|
||||
internal `{"SyncFolders": {"items": […]}}`, so scripts never name an internal
|
||||
variant.
|
||||
- **Errors still fail.** A daemon-side error prints its JSON body (with a
|
||||
machine-readable `kind`) on stdout *and* exits non-zero, so `set -e` and
|
||||
`if pdfs …` behave as expected rather than treating a failed lookup as
|
||||
success.
|
||||
|
||||
## Performance & Caching
|
||||
|
||||
The client includes several optimizations designed for high efficiency, a low memory footprint, and a responsive user experience:
|
||||
|
||||
+146
-23
@@ -23,10 +23,74 @@ use pdfs_core::db::Db;
|
||||
about = "Proton Drive for Linux (Files On-Demand)"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Emit machine-readable JSON instead of formatted text.
|
||||
///
|
||||
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
|
||||
/// `devices list`, `transfers`, `activity`, `cache inspect`). Commands that
|
||||
/// perform an action keep their human output — a script that needs to know
|
||||
/// whether one succeeded has the exit code.
|
||||
#[arg(long, global = true)]
|
||||
json: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
/// Whether `--json` was passed. A global rather than a threaded parameter
|
||||
/// because it is read at the leaves — one line per query command — and
|
||||
/// threading a display flag through every command signature would cost more
|
||||
/// than it explains.
|
||||
static JSON_OUTPUT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn json_enabled() -> bool {
|
||||
JSON_OUTPUT.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The daemon's reply as JSON, with the response-variant tag stripped.
|
||||
///
|
||||
/// Serializing [`CtlResponse`] directly yields serde's externally tagged form,
|
||||
/// `{"SyncFolders": {"items": […]}}`, which makes every script write
|
||||
/// `jq '.SyncFolders.items[]'` and couples it to the name of an internal enum
|
||||
/// variant. The payload is what the caller asked for, so that is what is
|
||||
/// emitted: `{"items": […]}`.
|
||||
///
|
||||
/// The reply is still serialized from the daemon's own type rather than
|
||||
/// rebuilt by hand, so new fields appear in the JSON without anyone
|
||||
/// remembering to add them here.
|
||||
fn response_payload(response: &CtlResponse) -> Result<serde_json::Value> {
|
||||
let value = serde_json::to_value(response)?;
|
||||
// Every variant serializes as a one-key object keyed by the variant name;
|
||||
// anything else is left alone rather than guessed at.
|
||||
if let serde_json::Value::Object(map) = &value
|
||||
&& map.len() == 1
|
||||
&& let Some((_, payload)) = map.iter().next()
|
||||
{
|
||||
return Ok(payload.clone());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Print the daemon's reply as JSON if `--json` is in effect, reporting whether
|
||||
/// it did so — a `true` means the caller should skip its human formatting.
|
||||
///
|
||||
/// A daemon-side error still prints its payload, so a script can read the
|
||||
/// `kind`, but then fails the command. Emitting the error and exiting `0` would
|
||||
/// make every `--json` caller inspect the body to find out whether it worked,
|
||||
/// when the exit code is the thing they already check.
|
||||
fn emit_json(response: &CtlResponse) -> Result<bool> {
|
||||
if !json_enabled() {
|
||||
return Ok(false);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&response_payload(response)?)?
|
||||
);
|
||||
if let CtlResponse::Error { message, kind } = response {
|
||||
bail!("{}", cli_error(*kind, message));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Log in to a Proton account (SRP + optional 2FA) and store the session.
|
||||
@@ -407,6 +471,7 @@ fn main() -> Result<()> {
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
||||
match cli.command {
|
||||
Command::Login { username } => cmd_login(username),
|
||||
Command::Logout => cmd_logout(),
|
||||
@@ -461,23 +526,29 @@ fn main() -> Result<()> {
|
||||
|
||||
fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
||||
match action {
|
||||
DeviceCmd::List => match control_request(CtlRequest::ListDevices)? {
|
||||
CtlResponse::Devices { items } if items.is_empty() => println!("No devices."),
|
||||
CtlResponse::Devices { items } => {
|
||||
for d in items {
|
||||
let sync = d
|
||||
.last_sync
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or_else(|| "never".to_string());
|
||||
println!(
|
||||
"{} {} (synced: {sync}) [{}]",
|
||||
d.device_type, d.name, d.uid
|
||||
);
|
||||
}
|
||||
DeviceCmd::List => {
|
||||
let response = control_request(CtlRequest::ListDevices)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
},
|
||||
match response {
|
||||
CtlResponse::Devices { items } if items.is_empty() => println!("No devices."),
|
||||
CtlResponse::Devices { items } => {
|
||||
for d in items {
|
||||
let sync = d
|
||||
.last_sync
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or_else(|| "never".to_string());
|
||||
println!(
|
||||
"{} {} (synced: {sync}) [{}]",
|
||||
d.device_type, d.name, d.uid
|
||||
);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
}
|
||||
DeviceCmd::Rename { uid, name } => {
|
||||
ok_or_bail(control_request(CtlRequest::RenameDevice { uid, name })?)?
|
||||
}
|
||||
@@ -497,7 +568,12 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
.to_string();
|
||||
ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)?
|
||||
}
|
||||
SyncCmd::List => match control_request(CtlRequest::ListSyncFolders)? {
|
||||
SyncCmd::List => {
|
||||
let response = control_request(CtlRequest::ListSyncFolders)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::SyncFolders { items } if items.is_empty() => {
|
||||
println!("No synced folders.")
|
||||
}
|
||||
@@ -537,7 +613,8 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
SyncCmd::Rm { id, delete_remote } => {
|
||||
ok_or_bail(control_request(CtlRequest::RemoveSyncFolder {
|
||||
id,
|
||||
@@ -696,7 +773,11 @@ fn cmd_shared_with_me() -> Result<()> {
|
||||
}
|
||||
|
||||
fn cmd_activity(limit: usize) -> Result<()> {
|
||||
match control_request(CtlRequest::ListActivity { limit })? {
|
||||
let response = control_request(CtlRequest::ListActivity { limit })?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Activity { items } if items.is_empty() => println!("No recent activity."),
|
||||
CtlResponse::Activity { items } => {
|
||||
for a in items {
|
||||
@@ -872,6 +953,32 @@ fn cmd_logout() -> Result<()> {
|
||||
}
|
||||
|
||||
fn cmd_status() -> Result<()> {
|
||||
// `status` is the one query that merges two sources — the local keyring and
|
||||
// the daemon — so its JSON is composed here rather than being a daemon reply
|
||||
// passed through. A script asking for status wants both halves in one
|
||||
// object, including the "logged in but no daemon" case that has no reply at
|
||||
// all.
|
||||
if json_enabled() {
|
||||
let session = match auth::load() {
|
||||
Ok(s) => Some(s),
|
||||
Err(pdfs_core::Error::NotLoggedIn) => None,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mount = match control_request(CtlRequest::Status) {
|
||||
Ok(r @ CtlResponse::Status { .. }) => response_payload(&r)?,
|
||||
_ => serde_json::Value::Null,
|
||||
};
|
||||
let out = serde_json::json!({
|
||||
"logged_in": session.is_some(),
|
||||
"username": session.as_ref().map(|s| s.username.clone()),
|
||||
"user_id": session.as_ref().map(|s| s.user_id.clone()),
|
||||
"scopes": session.as_ref().map(|s| s.scopes.clone()),
|
||||
"mount": mount,
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&out)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match auth::load() {
|
||||
Ok(s) => {
|
||||
println!("Logged in as {} (user {})", s.username, s.user_id);
|
||||
@@ -1032,7 +1139,11 @@ fn cmd_unpin(path: PathBuf) -> Result<()> {
|
||||
|
||||
fn cmd_transfers() -> Result<()> {
|
||||
use pdfs_core::control::TransferDirection;
|
||||
match control_request(CtlRequest::GetQueueStatus)? {
|
||||
let response = control_request(CtlRequest::GetQueueStatus)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Transfers { items, jobs } if items.is_empty() && jobs.is_empty() => {
|
||||
println!("Nothing in progress.")
|
||||
}
|
||||
@@ -1079,7 +1190,11 @@ fn cmd_transfers() -> Result<()> {
|
||||
}
|
||||
|
||||
fn cmd_pins() -> Result<()> {
|
||||
match control_request(CtlRequest::ListPins)? {
|
||||
let response = control_request(CtlRequest::ListPins)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Pins { pins } if pins.is_empty() => println!("No pinned files."),
|
||||
CtlResponse::Pins { pins } => {
|
||||
for p in pins {
|
||||
@@ -1097,7 +1212,11 @@ fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
|
||||
Some(p) => path_arg(&p)?,
|
||||
None => String::new(),
|
||||
};
|
||||
match control_request(CtlRequest::ListDir { path })? {
|
||||
let response = control_request(CtlRequest::ListDir { path })?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Entries { entries } if entries.is_empty() => println!("(empty)"),
|
||||
CtlResponse::Entries { entries } => {
|
||||
for e in entries {
|
||||
@@ -1380,7 +1499,11 @@ fn cmd_cache(action: CacheCmd) -> Result<()> {
|
||||
}
|
||||
|
||||
fn cmd_cache_inspect(deep: bool) -> Result<()> {
|
||||
match control_request(CtlRequest::CacheInspect { deep })? {
|
||||
let response = control_request(CtlRequest::CacheInspect { deep })?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::CacheReport {
|
||||
schema_version,
|
||||
db_bytes,
|
||||
|
||||
Reference in new issue
Block a user