qnap 0.1.12

CLI for QNAP NAS management — human-friendly output and JSON mode for agent use
use anyhow::Result;
use serde::{Deserialize, Serialize};

use crate::client::QnapClient;
use crate::output::{OutputFormat, ShareRow, print_shares};

#[derive(Debug, Deserialize)]
struct ShareEntry {
    id: String,
    text: String,
    real_total: Option<u64>,
}

#[derive(Debug, Serialize, PartialEq)]
struct ShareOutput {
    name: String,
    path: String,
    items_count: Option<u64>,
}

#[derive(Serialize)]
struct PagedShares<'a> {
    items: &'a [ShareOutput],
    total: usize,
    limit: usize,
    offset: usize,
}

pub async fn run(
    client: &QnapClient,
    fmt: OutputFormat,
    limit: usize,
    offset: usize,
    _fields: Option<&str>,
) -> Result<()> {
    let entries: Vec<ShareEntry> = client
        .get_json(
            "/cgi-bin/filemanager/utilRequest.cgi",
            &[("func", "get_tree"), ("node", "share_root")],
        )
        .await?;

    let all_rows: Vec<ShareOutput> = entries
        .into_iter()
        .map(|entry| ShareOutput {
            name: entry.text,
            path: entry.id,
            items_count: entry.real_total,
        })
        .collect();

    let total = all_rows.len();
    let page: &[ShareOutput] = {
        let start = offset.min(total);
        let end = (offset + limit).min(total);
        &all_rows[start..end]
    };

    if fmt.is_json() {
        let paged = PagedShares {
            items: page,
            total,
            limit,
            offset,
        };
        println!(
            "{}",
            serde_json::to_string_pretty(&paged).unwrap_or_default()
        );
        return Ok(());
    }

    let table_rows: Vec<ShareRow> = page
        .iter()
        .map(|row| ShareRow {
            name: row.name.clone(),
            path: row.path.clone(),
            items: row
                .items_count
                .map(|value| value.to_string())
                .unwrap_or_else(|| "-".to_string()),
        })
        .collect();
    print_shares(&table_rows);
    Ok(())
}

#[cfg(test)]
mod fixture_tests {
    use super::*;

    const SHARES: &str = include_str!("../../tests/fixtures/ts-xa28a-qts52/shares.json");

    #[test]
    fn fixture_shares_parses() {
        let entries: Vec<ShareEntry> =
            serde_json::from_str(SHARES).expect("failed to parse shares fixture");
        assert!(!entries.is_empty(), "no shares found in fixture");
    }

    #[test]
    fn fixture_shares_have_id_and_name() {
        let entries: Vec<ShareEntry> = serde_json::from_str(SHARES).unwrap();
        for entry in &entries {
            assert!(!entry.id.is_empty(), "share entry has empty id");
            assert!(!entry.text.is_empty(), "share entry has empty text");
        }
    }

    #[test]
    fn fixture_shares_output_is_typed() {
        let entries: Vec<ShareEntry> = serde_json::from_str(SHARES).unwrap();
        let rows: Vec<ShareOutput> = entries
            .into_iter()
            .map(|entry| ShareOutput {
                name: entry.text,
                path: entry.id,
                items_count: entry.real_total,
            })
            .collect();
        assert_eq!(rows[0].items_count, Some(2));
    }
}