sproto 0.1.0

Rust client for the Synology Drive sync protocol
Documentation
#[path = "support/mod.rs"]
mod support;

use anyhow::{Context, Result};
use clap::Parser;

use sproto::actions::list::{FileType, NodeInfo};

#[derive(Parser)]
#[command(name = "sproto-ls", about = "List files on a Synology Drive share")]
#[allow(
    clippy::struct_excessive_bools,
    reason = "CLI flags are naturally boolean"
)]
struct Args {
    /// Remote path to list [default: /]
    #[arg(default_value = "/")]
    path: String,

    /// Long format (size, date, name)
    #[arg(short, long)]
    long: bool,

    /// Human-readable sizes (with -l)
    #[arg(short = 'H', long)]
    human_readable: bool,

    /// Sort by file size
    #[arg(short = 'S', long)]
    sort_size: bool,

    /// Sort by modification time
    #[arg(short = 't', long)]
    sort_time: bool,

    /// Reverse sort order
    #[arg(short = 'r', long)]
    reverse: bool,

    /// Recursive listing
    #[arg(short = 'R', long)]
    recursive: bool,

    #[command(flatten)]
    conn: support::ConnectionArgs,
}

#[tokio::main]
async fn main() -> Result<()> {
    support::init_tracing();
    let args = Args::parse();

    let client = args.conn.connect().await?;
    let view_id = args.conn.resolve_view(&client).await?;

    if args.recursive {
        client.warm_pool(5).await?;
        list_recursive(&client, &args, view_id, &args.path).await?;
    } else {
        let nodes = client
            .list_dir(view_id, &args.path)
            .await
            .with_context(|| format!("failed to list '{}'", args.path))?;
        print_nodes(&args, &nodes);
    }

    Ok(())
}

#[allow(
    clippy::cast_precision_loss,
    reason = "file sizes don't need u64 precision for display"
)]
fn format_size(bytes: u64) -> String {
    const UNITS: &[&str] = &["B", "K", "M", "G", "T"];
    if bytes < 1024 {
        return format!("{bytes:>4}");
    }
    let mut size = bytes as f64;
    for unit in &UNITS[1..] {
        size /= 1024.0;
        if size < 1024.0 {
            return if size >= 10.0 {
                format!("{size:>3.0}{unit}")
            } else {
                format!("{size:>3.1}{unit}")
            };
        }
    }
    format!("{size:>.0}T")
}

fn format_date(mtime: u64) -> String {
    let secs = mtime.cast_signed();
    let days = secs / 86400;
    let time_of_day = secs % 86400;
    let hours = time_of_day / 3600;
    let minutes = (time_of_day % 3600) / 60;

    let (year, month, day) = epoch_days_to_date(days);

    format!("{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}")
}

#[allow(
    clippy::cast_possible_truncation,
    reason = "date algorithm operates within safe ranges for valid unix timestamps"
)]
const fn epoch_days_to_date(days: i64) -> (i64, u32, u32) {
    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097) as u32;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
    let y = (yoe as i64) + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y, m, d)
}

fn print_nodes(args: &Args, nodes: &[NodeInfo]) {
    let mut filtered: Vec<_> = nodes.iter().filter(|n| !n.is_removed).collect();

    if args.sort_size {
        filtered.sort_by_key(|n| n.file_size);
    } else if args.sort_time {
        filtered.sort_by_key(|n| n.mtime);
    } else {
        filtered.sort_by_key(|n| &n.name);
    }
    if args.reverse {
        filtered.reverse();
    }

    for node in &filtered {
        let suffix = match node.file_type {
            FileType::Dir => "/",
            FileType::Symlink => "@",
            FileType::File => "",
        };

        if args.long {
            let type_char = match node.file_type {
                FileType::Dir => 'd',
                FileType::Symlink => 'l',
                FileType::File => '-',
            };
            let size = if args.human_readable {
                format_size(node.file_size)
            } else {
                format!("{:>10}", node.file_size)
            };
            let date = format_date(node.mtime);
            println!("{type_char} {size}  {date}  {}{suffix}", node.name);
        } else {
            println!("{}{suffix}", node.name);
        }
    }
}

async fn list_recursive(
    client: &sproto::Client,
    args: &Args,
    view_id: u64,
    root: &str,
) -> Result<()> {
    let mut results: Vec<(String, Vec<NodeInfo>)> = Vec::new();
    let mut dirs_to_list = vec![root.to_string()];

    while !dirs_to_list.is_empty() {
        let mut handles = Vec::new();
        for dir in std::mem::take(&mut dirs_to_list) {
            let client = client.clone();
            handles.push(tokio::spawn(async move {
                let nodes = client.list_dir(view_id, &dir).await;
                (dir, nodes)
            }));
        }

        for handle in handles {
            let (dir, nodes) = handle
                .await
                .map_err(|e| anyhow::anyhow!("task failed: {e}"))?;
            let nodes = nodes.with_context(|| format!("failed to list '{dir}'"))?;

            for node in &nodes {
                if !node.is_removed && node.file_type == FileType::Dir {
                    let child = format!("{}/{}", dir.trim_end_matches('/'), node.name);
                    dirs_to_list.push(child);
                }
            }

            results.push((dir, nodes));
        }
    }

    results.sort_by(|a, b| a.0.cmp(&b.0));

    for (i, (path, nodes)) in results.iter().enumerate() {
        if results.len() > 1 && (i > 0 || *path != root) {
            if i > 0 {
                println!();
            }
            println!("{path}:");
        }
        print_nodes(args, nodes);
    }

    Ok(())
}