Compare commits
8
Commits
56b5abc1d1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e459dfc78 | ||
|
|
1b12066e55 | ||
|
|
87a955ac67 | ||
|
|
71bc2bf2ba | ||
|
|
5833c55cd9 | ||
|
|
df07eec5fa | ||
|
|
c83708a727 | ||
|
|
213ed0b834 |
No files matched your search
@@ -0,0 +1,122 @@
|
|||||||
|
name: Release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build (${{ matrix.target }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
use_cross: false
|
||||||
|
archive_ext: tar.gz
|
||||||
|
- os: ubuntu-24.04-arm
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
|
use_cross: false
|
||||||
|
archive_ext: tar.gz
|
||||||
|
- os: macos-latest
|
||||||
|
target: x86_64-apple-darwin
|
||||||
|
use_cross: false
|
||||||
|
archive_ext: tar.gz
|
||||||
|
- os: macos-latest
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
use_cross: false
|
||||||
|
archive_ext: tar.gz
|
||||||
|
- os: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
use_cross: false
|
||||||
|
archive_ext: zip
|
||||||
|
- os: windows-latest
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
|
use_cross: false
|
||||||
|
archive_ext: zip
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
- name: Install cross
|
||||||
|
if: matrix.use_cross
|
||||||
|
uses: taiki-e/install-action@cross
|
||||||
|
- name: Build binary
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.use_cross }}" = "true" ]; then
|
||||||
|
cross build --release --target ${{ matrix.target }}
|
||||||
|
else
|
||||||
|
cargo build --release --target ${{ matrix.target }}
|
||||||
|
fi
|
||||||
|
- name: Package archive (Unix)
|
||||||
|
if: matrix.archive_ext == 'tar.gz'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
ARCHIVE_NAME="onx-${{ github.ref_name }}-${{ matrix.target }}"
|
||||||
|
STAGE_DIR="staging/${ARCHIVE_NAME}"
|
||||||
|
mkdir -p "${STAGE_DIR}"
|
||||||
|
|
||||||
|
cp "target/${{ matrix.target }}/release/onx" "${STAGE_DIR}/"
|
||||||
|
cp README.md LICENSE.md "${STAGE_DIR}/"
|
||||||
|
|
||||||
|
tar -czf "${ARCHIVE_NAME}.tar.gz" -C staging "${ARCHIVE_NAME}"
|
||||||
|
sha256sum "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256"
|
||||||
|
|
||||||
|
mkdir dist
|
||||||
|
mv "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}.tar.gz.sha256" dist/
|
||||||
|
- name: Package archive (Windows)
|
||||||
|
if: matrix.archive_ext == 'zip'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ArchiveName = "onx-${{ github.ref_name }}-${{ matrix.target }}"
|
||||||
|
$StageDir = "staging/$ArchiveName"
|
||||||
|
New-Item -ItemType Directory -Force -Path $StageDir | Out-Null
|
||||||
|
|
||||||
|
Copy-Item "target/${{ matrix.target }}/release/onx.exe" -Destination $StageDir
|
||||||
|
Copy-Item "README.md", "LICENSE.md" -Destination $StageDir
|
||||||
|
|
||||||
|
Compress-Archive -Path $StageDir -DestinationPath "$ArchiveName.zip"
|
||||||
|
$Hash = (Get-FileHash -Path "$ArchiveName.zip" -Algorithm SHA256).Hash.ToLower()
|
||||||
|
"$Hash $ArchiveName.zip" | Out-File -Encoding ascii "$ArchiveName.zip.sha256"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||||
|
Move-Item "$ArchiveName.zip", "$ArchiveName.zip.sha256" -Destination dist/
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: assets-${{ matrix.target }}
|
||||||
|
path: dist/*
|
||||||
|
publish-release:
|
||||||
|
name: Publish Release
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
path: all-assets
|
||||||
|
pattern: assets-*
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Generate SHA256SUMS.txt
|
||||||
|
working-directory: all-assets
|
||||||
|
run: |
|
||||||
|
cat *.sha256 > SHA256SUMS.txt
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: Release ${{ github.ref_name }}
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
all-assets/*.tar.gz
|
||||||
|
all-assets/*.zip
|
||||||
|
all-assets/*.sha256
|
||||||
|
all-assets/SHA256SUMS.txt
|
||||||
Generated
+1
@@ -797,6 +797,7 @@ dependencies = [
|
|||||||
name = "onx"
|
name = "onx"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ license = "MIT"
|
|||||||
repository = "https://github.com/StevanFreeborn/onx"
|
repository = "https://github.com/StevanFreeborn/onx"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
bytes = "1.12.1"
|
||||||
chrono = { version = "0.4.45", features = ["serde"] }
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
clap = { version = "4.6.6", features = ["derive", "env"] }
|
clap = { version = "4.6.6", features = ["derive", "env"] }
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
|
|||||||
@@ -1,3 +1,55 @@
|
|||||||
# Onx
|
# Onx
|
||||||
|
|
||||||
An agent-friendly CLI (built in Rust btw) for the Onspring API.
|
An agent-friendly CLI (built in Rust btw) for the Onspring API.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Linux & macOS
|
||||||
|
|
||||||
|
Install via shell script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/StevanFreeborn/onx/main/scripts/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
To install a specific version or customize the install directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/StevanFreeborn/onx/main/scripts/install.sh | bash -s -- --version v0.1.0 --to ~/.local/bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
Install via PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/StevanFreeborn/onx/main/scripts/install.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
To install a specific version or customize the install directory:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/StevanFreeborn/onx/main/scripts/install.ps1))) -Version v0.1.0 -InstallDir "$HOME\.onx\bin"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`onx` can be configured using command-line arguments, a JSON configuration file, or environment variables.
|
||||||
|
|
||||||
|
### Precedence
|
||||||
|
|
||||||
|
1. **Command-line flags** (`--api-key`, `--base-url`, `--pretty`)
|
||||||
|
2. **Configuration file** (`~/.config/onx/config.json` or `--config <PATH>`)
|
||||||
|
3. **Environment variables** (`ONSPRING_API_KEY`, `ONSPRING_BASE_URL`)
|
||||||
|
|
||||||
|
### Configuration File Format
|
||||||
|
|
||||||
|
Create `~/.config/onx/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "your-api-key",
|
||||||
|
"baseUrl": "https://api.onspring.com",
|
||||||
|
"pretty": true
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Alias("v")]
|
||||||
|
[string]$Version,
|
||||||
|
|
||||||
|
[Alias("t")]
|
||||||
|
[string]$InstallDir = "$HOME\.onx\bin"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$Repo = "StevanFreeborn/onx"
|
||||||
|
$GithubUrl = "https://github.com/$Repo"
|
||||||
|
$BinaryName = "onx.exe"
|
||||||
|
|
||||||
|
$Arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
|
||||||
|
"aarch64"
|
||||||
|
} else {
|
||||||
|
"x86_64"
|
||||||
|
}
|
||||||
|
|
||||||
|
$Target = "$Arch-pc-windows-msvc"
|
||||||
|
|
||||||
|
if (-not $Version) {
|
||||||
|
Write-Host "Finding latest release of $Repo..."
|
||||||
|
|
||||||
|
$ApiUrl = "https://api.github.com/repos/$Repo/releases/latest"
|
||||||
|
|
||||||
|
try {
|
||||||
|
$Release = Invoke-RestMethod -Uri $ApiUrl -Headers @{ "User-Agent" = "onx-installer" }
|
||||||
|
$Version = $Release.tag_name
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Could not query GitHub API for latest release, falling back to web request..."
|
||||||
|
|
||||||
|
$Response = Invoke-WebRequest -Uri "$GithubUrl/releases/latest" -MaximumRedirection 0 -ErrorAction SilentlyContinue
|
||||||
|
$Location = $Response.Headers["Location"]
|
||||||
|
|
||||||
|
if ($Location) {
|
||||||
|
$Version = ($Location -split '/')[-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Version -and -not $Version.StartsWith("v")) {
|
||||||
|
$Version = "v$Version"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Version) {
|
||||||
|
Write-Error "Could not determine version to install."
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$ArchiveName = "onx-$Version-$Target.zip"
|
||||||
|
$DownloadUrl = "$GithubUrl/releases/download/$Version/$ArchiveName"
|
||||||
|
$ChecksumUrl = "$DownloadUrl.sha256"
|
||||||
|
|
||||||
|
$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())
|
||||||
|
New-Item -ItemType Directory -Force -Path $TempDir | Out-Null
|
||||||
|
|
||||||
|
function Download-File {
|
||||||
|
param(
|
||||||
|
[string]$Uri,
|
||||||
|
[string]$OutFile,
|
||||||
|
[int]$MaxRetries = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
$Attempt = 0
|
||||||
|
$DelaySeconds = 2
|
||||||
|
|
||||||
|
while ($Attempt -lt $MaxRetries) {
|
||||||
|
$Attempt++
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -Headers @{ "User-Agent" = "onx-installer" }
|
||||||
|
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
$ErrorMessage = $_.Exception.Message
|
||||||
|
|
||||||
|
if ($Attempt -lt $MaxRetries) {
|
||||||
|
Write-Warning "Download attempt $Attempt failed ($ErrorMessage). Retrying in ${DelaySeconds}s..."
|
||||||
|
|
||||||
|
Start-Sleep -Seconds $DelaySeconds
|
||||||
|
|
||||||
|
$DelaySeconds = $DelaySeconds * 2
|
||||||
|
} else {
|
||||||
|
Write-Error "Failed to download $Uri after $MaxRetries attempts: $ErrorMessage"
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ArchiveFile = Join-Path $TempDir $ArchiveName
|
||||||
|
$ChecksumFile = Join-Path $TempDir "$ArchiveName.sha256"
|
||||||
|
|
||||||
|
Write-Host "Downloading $BinaryName $Version for $Target..."
|
||||||
|
|
||||||
|
Download-File -Uri $DownloadUrl -OutFile $ArchiveFile
|
||||||
|
Download-File -Uri $ChecksumUrl -OutFile $ChecksumFile
|
||||||
|
|
||||||
|
Write-Host "Verifying SHA256 checksum..."
|
||||||
|
|
||||||
|
$ExpectedHash = ((Get-Content $ChecksumFile) -split '\s+')[0].Trim().ToLower()
|
||||||
|
$ActualHash = (Get-FileHash -Path $ArchiveFile -Algorithm SHA256).Hash.ToLower()
|
||||||
|
|
||||||
|
if ($ExpectedHash -ne $ActualHash) {
|
||||||
|
Write-Error "Checksum mismatch!`n Expected: $ExpectedHash`n Actual: $ActualHash"
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Extracting $ArchiveName..."
|
||||||
|
|
||||||
|
$ExtractDir = Join-Path $TempDir "extracted"
|
||||||
|
Expand-Archive -Path $ArchiveFile -DestinationPath $ExtractDir -Force
|
||||||
|
|
||||||
|
$FoundBin = Get-ChildItem -Path $ExtractDir -Recurse -Filter $BinaryName | Select-Object -First 1
|
||||||
|
|
||||||
|
if (-not $FoundBin) {
|
||||||
|
Write-Error "Binary '$BinaryName' not found inside archive."
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||||
|
$TargetFile = Join-Path $InstallDir $BinaryName
|
||||||
|
Copy-Item -Path $FoundBin.FullName -Destination $TargetFile -Force
|
||||||
|
|
||||||
|
Write-Host "$BinaryName $Version installed successfully to $TargetFile!" -ForegroundColor Green
|
||||||
|
|
||||||
|
$UserPath = [Environment]::GetEnvironmentVariable("Path", [EnvironmentVariableTarget]::User)
|
||||||
|
$PathParts = ($UserPath -split ';') | Where-Object { $_ -ne "" }
|
||||||
|
|
||||||
|
if ($PathParts -notcontains $InstallDir) {
|
||||||
|
Write-Host "Adding $InstallDir to user PATH..."
|
||||||
|
|
||||||
|
$NewUserPath = "$UserPath;$InstallDir".Trim(';')
|
||||||
|
[Environment]::SetEnvironmentVariable("Path", $NewUserPath, [EnvironmentVariableTarget]::User)
|
||||||
|
$env:Path = "$env:Path;$InstallDir"
|
||||||
|
|
||||||
|
Write-Host "Added $InstallDir to user PATH. You may need to restart your terminal for the change to take effect." -ForegroundColor Cyan
|
||||||
|
} else {
|
||||||
|
Write-Host "$InstallDir is already in your PATH."
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (Test-Path $TempDir) {
|
||||||
|
Remove-Item -Recurse -Force -Path $TempDir
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="StevanFreeborn/onx"
|
||||||
|
GITHUB_URL="https://github.com/${REPO}"
|
||||||
|
BINARY_NAME="onx"
|
||||||
|
|
||||||
|
REQUESTED_VERSION=""
|
||||||
|
INSTALL_DIR="${HOME}/.local/bin"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-v|--version)
|
||||||
|
REQUESTED_VERSION="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-t|--to)
|
||||||
|
INSTALL_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
cat <<EOF
|
||||||
|
onx installer script
|
||||||
|
|
||||||
|
Usage: install.sh [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-v, --version <VERSION> Install a specific version (e.g., v0.1.0)
|
||||||
|
-t, --to <DIR> Install directory (default: ~/.local/bin)
|
||||||
|
-h, --help Show this help message
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
OS_TYPE="$(uname -s)"
|
||||||
|
case "${OS_TYPE}" in
|
||||||
|
Linux)
|
||||||
|
OS="unknown-linux-gnu"
|
||||||
|
;;
|
||||||
|
Darwin)
|
||||||
|
OS="apple-darwin"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unsupported operating system: ${OS_TYPE}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
ARCH_TYPE="$(uname -m)"
|
||||||
|
case "${ARCH_TYPE}" in
|
||||||
|
x86_64|amd64)
|
||||||
|
ARCH="x86_64"
|
||||||
|
;;
|
||||||
|
arm64|aarch64)
|
||||||
|
ARCH="aarch64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unsupported architecture: ${ARCH_TYPE}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
TARGET="${ARCH}-${OS}"
|
||||||
|
|
||||||
|
download() {
|
||||||
|
local url="$1"
|
||||||
|
local output="$2"
|
||||||
|
local max_retries=3
|
||||||
|
local attempt=0
|
||||||
|
local delay=2
|
||||||
|
|
||||||
|
while [ "$attempt" -lt "$max_retries" ]; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
if curl -fsSL --retry 3 --retry-delay 2 -A "onx-installer" "$url" -o "$output"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
|
if wget -q --tries=3 --waitretry=2 --user-agent="onx-installer" -O "$output" "$url"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Error: Neither curl nor wget was found. Please install one of them." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$attempt" -lt "$max_retries" ]; then
|
||||||
|
echo "Download attempt ${attempt} failed. Retrying in ${delay}s..." >&2
|
||||||
|
sleep "$delay"
|
||||||
|
delay=$((delay * 2))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Error: Failed to download ${url} after ${max_retries} attempts." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "${REQUESTED_VERSION}" ]; then
|
||||||
|
echo "Finding latest release of ${REPO}..."
|
||||||
|
|
||||||
|
LATEST_URL="https://api.github.com/repos/${REPO}/releases/latest"
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
RELEASE_JSON="$(curl -fsSL "${LATEST_URL}" || true)"
|
||||||
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
|
RELEASE_JSON="$(wget -qO- "${LATEST_URL}" || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${RELEASE_JSON}" ]; then
|
||||||
|
VERSION="$(echo "${RELEASE_JSON}" | grep -m 1 '"tag_name":' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${VERSION:-}" ]; then
|
||||||
|
echo "Could not query GitHub API for latest release, falling back to git redirect..."
|
||||||
|
|
||||||
|
VERSION="$(curl -fsSL -o /dev/null -w "%{url_effective}" "${GITHUB_URL}/releases/latest" | rev | cut -d/ -f1 | rev)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
VERSION="${REQUESTED_VERSION}"
|
||||||
|
if [[ "${VERSION}" != v* ]]; then
|
||||||
|
VERSION="v${VERSION}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${VERSION}" ]; then
|
||||||
|
echo "Error: Could not determine version to install." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCHIVE_NAME="onx-${VERSION}-${TARGET}.tar.gz"
|
||||||
|
DOWNLOAD_URL="${GITHUB_URL}/releases/download/${VERSION}/${ARCHIVE_NAME}"
|
||||||
|
CHECKSUM_URL="${DOWNLOAD_URL}.sha256"
|
||||||
|
|
||||||
|
TMP_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t 'onx-install')"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "${TMP_DIR}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Downloading ${BINARY_NAME} ${VERSION} for ${TARGET}..."
|
||||||
|
|
||||||
|
download "${DOWNLOAD_URL}" "${TMP_DIR}/${ARCHIVE_NAME}"
|
||||||
|
download "${CHECKSUM_URL}" "${TMP_DIR}/${ARCHIVE_NAME}.sha256"
|
||||||
|
|
||||||
|
echo "Verifying SHA256 checksum..."
|
||||||
|
|
||||||
|
EXPECTED_SHA="$(awk '{print $1}' "${TMP_DIR}/${ARCHIVE_NAME}.sha256")"
|
||||||
|
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
ACTUAL_SHA="$(sha256sum "${TMP_DIR}/${ARCHIVE_NAME}" | awk '{print $1}')"
|
||||||
|
elif command -v shasum >/dev/null 2>&1; then
|
||||||
|
ACTUAL_SHA="$(shasum -a 256 "${TMP_DIR}/${ARCHIVE_NAME}" | awk '{print $1}')"
|
||||||
|
else
|
||||||
|
echo "Warning: sha256sum/shasum not found, skipping checksum verification."
|
||||||
|
ACTUAL_SHA="${EXPECTED_SHA}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]; then
|
||||||
|
echo "Error: Checksum mismatch!" >&2
|
||||||
|
echo " Expected: ${EXPECTED_SHA}" >&2
|
||||||
|
echo " Actual: ${ACTUAL_SHA}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Extracting ${ARCHIVE_NAME}..."
|
||||||
|
|
||||||
|
mkdir -p "${TMP_DIR}/extracted"
|
||||||
|
tar -xzf "${TMP_DIR}/${ARCHIVE_NAME}" -C "${TMP_DIR}/extracted"
|
||||||
|
|
||||||
|
EXTRACTED_BIN="$(find "${TMP_DIR}/extracted" -type f -name "${BINARY_NAME}" | head -n 1)"
|
||||||
|
|
||||||
|
if [ -z "${EXTRACTED_BIN}" ]; then
|
||||||
|
echo "Error: Binary '${BINARY_NAME}' not found inside archive." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${INSTALL_DIR}"
|
||||||
|
chmod +x "${EXTRACTED_BIN}"
|
||||||
|
mv "${EXTRACTED_BIN}" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||||
|
|
||||||
|
echo "${BINARY_NAME} ${VERSION} installed successfully to ${INSTALL_DIR}/${BINARY_NAME}!"
|
||||||
|
|
||||||
|
case ":${PATH}:" in
|
||||||
|
*:"${INSTALL_DIR}":*)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo ""
|
||||||
|
echo "Notice: ${INSTALL_DIR} is not in your PATH."
|
||||||
|
|
||||||
|
PROFILE_FILE=""
|
||||||
|
if [ -n "${ZSH_VERSION:-}" ] || [ -f "${HOME}/.zshrc" ]; then
|
||||||
|
PROFILE_FILE="${HOME}/.zshrc"
|
||||||
|
elif [ -n "${BASH_VERSION:-}" ] || [ -f "${HOME}/.bashrc" ]; then
|
||||||
|
PROFILE_FILE="${HOME}/.bashrc"
|
||||||
|
elif [ -f "${HOME}/.profile" ]; then
|
||||||
|
PROFILE_FILE="${HOME}/.profile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${PROFILE_FILE}" ]; then
|
||||||
|
PATH_EXPORT="export PATH=\"${INSTALL_DIR}:\$PATH\""
|
||||||
|
if ! grep -qs "${INSTALL_DIR}" "${PROFILE_FILE}"; then
|
||||||
|
echo "${PATH_EXPORT}" >> "${PROFILE_FILE}"
|
||||||
|
echo "Added ${INSTALL_DIR} to PATH in ${PROFILE_FILE}."
|
||||||
|
echo "Please restart your shell or run: source ${PROFILE_FILE}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "To add it to your PATH, add the following line to your shell configuration:"
|
||||||
|
echo " export PATH=\"${INSTALL_DIR}:\$PATH\""
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+569
-38
@@ -1,6 +1,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Parser)]
|
#[derive(Debug, PartialEq, Parser)]
|
||||||
#[command(name = "onx")]
|
#[command(name = "onx")]
|
||||||
@@ -9,16 +10,19 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
|||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
#[arg(short = 'k')]
|
#[arg(short = 'k')]
|
||||||
#[arg(env = "ONSPRING_API_KEY")]
|
|
||||||
#[arg(help = "The API key for the Onspring instance")]
|
#[arg(help = "The API key for the Onspring instance")]
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
#[arg(short = 'u')]
|
#[arg(short = 'u')]
|
||||||
#[arg(env = "ONSPRING_BASE_URL")]
|
|
||||||
#[arg(help = "The base URL for the Onspring API")]
|
#[arg(help = "The base URL for the Onspring API")]
|
||||||
pub base_url: Option<String>,
|
pub base_url: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(short = 'c')]
|
||||||
|
#[arg(help = "Path to a configuration JSON file")]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
#[arg(short = 'p')]
|
#[arg(short = 'p')]
|
||||||
#[arg(help = "Pretty-print JSON output")]
|
#[arg(help = "Pretty-print JSON output")]
|
||||||
@@ -51,6 +55,24 @@ pub enum Command {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: RecordsCommand,
|
command: RecordsCommand,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[command(about = "Perform operations against the files in the instance")]
|
||||||
|
Files {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: FilesCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(about = "Perform operations against the lists in the instance")]
|
||||||
|
Lists {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: ListsCommand,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(about = "Perform operations against the reports in the instance")]
|
||||||
|
Reports {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: ReportsCommand,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Subcommand)]
|
#[derive(Debug, PartialEq, Subcommand)]
|
||||||
@@ -60,19 +82,17 @@ pub enum AppsCommand {
|
|||||||
|
|
||||||
#[command(about = "Get information about an app")]
|
#[command(about = "Get information about an app")]
|
||||||
Get {
|
Get {
|
||||||
#[arg(long = "app-id")]
|
#[arg(long = "app")]
|
||||||
#[arg(short = 'i')]
|
#[arg(short = 'a')]
|
||||||
#[arg(alias = "app")]
|
|
||||||
#[arg(help = "App id to get")]
|
#[arg(help = "App id to get")]
|
||||||
app_id: i32,
|
app_id: i32,
|
||||||
},
|
},
|
||||||
|
|
||||||
#[command(about = "Get information for a batch of apps")]
|
#[command(about = "Get information for a batch of apps")]
|
||||||
BatchGet {
|
BatchGet {
|
||||||
#[arg(long = "ids")]
|
#[arg(long = "apps")]
|
||||||
#[arg(value_delimiter = ',')]
|
#[arg(value_delimiter = ',')]
|
||||||
#[arg(short = 'i')]
|
#[arg(short = 'a')]
|
||||||
#[arg(alias = "apps")]
|
|
||||||
#[arg(help = "Comma-separated list of app ids to get")]
|
#[arg(help = "Comma-separated list of app ids to get")]
|
||||||
ids: Vec<i32>,
|
ids: Vec<i32>,
|
||||||
},
|
},
|
||||||
@@ -82,9 +102,8 @@ pub enum AppsCommand {
|
|||||||
pub enum FieldsCommand {
|
pub enum FieldsCommand {
|
||||||
#[command(about = "Get information for a list of fields")]
|
#[command(about = "Get information for a list of fields")]
|
||||||
List {
|
List {
|
||||||
#[arg(long = "app-id")]
|
#[arg(long = "app")]
|
||||||
#[arg(short = 'i')]
|
#[arg(short = 'a')]
|
||||||
#[arg(alias = "app")]
|
|
||||||
#[arg(help = "App id whose fields will be retrieved")]
|
#[arg(help = "App id whose fields will be retrieved")]
|
||||||
app_id: i32,
|
app_id: i32,
|
||||||
|
|
||||||
@@ -94,19 +113,17 @@ pub enum FieldsCommand {
|
|||||||
|
|
||||||
#[command(about = "Get information about a field")]
|
#[command(about = "Get information about a field")]
|
||||||
Get {
|
Get {
|
||||||
#[arg(long = "field-id")]
|
#[arg(long = "field")]
|
||||||
#[arg(short = 'i')]
|
#[arg(short = 'f')]
|
||||||
#[arg(alias = "field")]
|
|
||||||
#[arg(help = "Field id to get")]
|
#[arg(help = "Field id to get")]
|
||||||
field_id: i32,
|
field_id: i32,
|
||||||
},
|
},
|
||||||
|
|
||||||
#[command(about = "Get information for a batch of fields")]
|
#[command(about = "Get information for a batch of fields")]
|
||||||
BatchGet {
|
BatchGet {
|
||||||
#[arg(long = "ids")]
|
#[arg(long = "fields")]
|
||||||
#[arg(value_delimiter = ',')]
|
#[arg(value_delimiter = ',')]
|
||||||
#[arg(short = 'i')]
|
#[arg(short = 'f')]
|
||||||
#[arg(alias = "fields")]
|
|
||||||
#[arg(help = "Comma-separated list of field ids to get")]
|
#[arg(help = "Comma-separated list of field ids to get")]
|
||||||
ids: Vec<i32>,
|
ids: Vec<i32>,
|
||||||
},
|
},
|
||||||
@@ -200,6 +217,168 @@ pub enum RecordsCommand {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Subcommand)]
|
||||||
|
pub enum FilesCommand {
|
||||||
|
#[command(about = "Get metadata info for a file")]
|
||||||
|
Info {
|
||||||
|
#[arg(long = "record")]
|
||||||
|
#[arg(short = 'r')]
|
||||||
|
#[arg(help = "Record id for record where the file is held")]
|
||||||
|
record_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "field")]
|
||||||
|
#[arg(short = 'f')]
|
||||||
|
#[arg(help = "Field id for field where the file is held")]
|
||||||
|
field_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "file")]
|
||||||
|
#[arg(short = 'l')]
|
||||||
|
#[arg(help = "The id of the file")]
|
||||||
|
file_id: i32,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(about = "Get the content for a file")]
|
||||||
|
Get {
|
||||||
|
#[arg(long = "record")]
|
||||||
|
#[arg(short = 'r')]
|
||||||
|
#[arg(help = "Record id for record where the file is held")]
|
||||||
|
record_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "field")]
|
||||||
|
#[arg(short = 'f')]
|
||||||
|
#[arg(help = "Field id for field where the file is held")]
|
||||||
|
field_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "file")]
|
||||||
|
#[arg(short = 'l')]
|
||||||
|
#[arg(help = "The id of the file")]
|
||||||
|
file_id: i32,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(short = 'o')]
|
||||||
|
#[arg(help = "The output path for the file")]
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(about = "Upload a file to a field on a record")]
|
||||||
|
Upload {
|
||||||
|
#[arg(long = "record")]
|
||||||
|
#[arg(short = 'r')]
|
||||||
|
#[arg(help = "Record id for record where the file should be uploaded")]
|
||||||
|
record_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "field")]
|
||||||
|
#[arg(short = 'f')]
|
||||||
|
#[arg(help = "Field id for field where the file should be uploaded")]
|
||||||
|
field_id: i32,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(short = 'i')]
|
||||||
|
#[arg(help = "The input path of the file to be uploaded")]
|
||||||
|
input: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(short = 's')]
|
||||||
|
#[arg(help = "Indicates whether file data should be read in from standard in")]
|
||||||
|
stdin: bool,
|
||||||
|
|
||||||
|
#[arg(long = "name")]
|
||||||
|
#[arg(short = 'n')]
|
||||||
|
#[arg(help = "The name of the file to be uploaded")]
|
||||||
|
file_name: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long = "type")]
|
||||||
|
#[arg(short = 't')]
|
||||||
|
#[arg(help = "The content type of the file to be uploaded")]
|
||||||
|
content_type: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(help = "Notes that should be included in the file upload")]
|
||||||
|
notes: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(help = "Modified date that should be included in the file upload as RFC3339 timestamp")]
|
||||||
|
modified_date: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(about = "Delete a file from a field on a record")]
|
||||||
|
Delete {
|
||||||
|
#[arg(long = "record")]
|
||||||
|
#[arg(short = 'r')]
|
||||||
|
#[arg(help = "Record id for record where the file is held")]
|
||||||
|
record_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "field")]
|
||||||
|
#[arg(short = 'f')]
|
||||||
|
#[arg(help = "Field id for field where the file is held")]
|
||||||
|
field_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "file")]
|
||||||
|
#[arg(short = 'l')]
|
||||||
|
#[arg(help = "The id of the file")]
|
||||||
|
file_id: i32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Subcommand)]
|
||||||
|
pub enum ListsCommand {
|
||||||
|
#[command(about = "Add or update an item for a specific list")]
|
||||||
|
SaveItem {
|
||||||
|
#[arg(long = "list")]
|
||||||
|
#[arg(short = 'l')]
|
||||||
|
#[arg(help = "List id for list where the item should be saved")]
|
||||||
|
list_id: i32,
|
||||||
|
|
||||||
|
#[command(flatten)]
|
||||||
|
body: BodySource,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(about = "Delete an item from a specific list")]
|
||||||
|
DeleteItem {
|
||||||
|
#[arg(long = "list")]
|
||||||
|
#[arg(short = 'l')]
|
||||||
|
#[arg(help = "List id for list where the item should be saved")]
|
||||||
|
list_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "item")]
|
||||||
|
#[arg(short = 'i')]
|
||||||
|
#[arg(help = "Id of the item that should be deleted")]
|
||||||
|
item_id: Uuid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Subcommand)]
|
||||||
|
pub enum ReportsCommand {
|
||||||
|
#[command(about = "Get the data for a specific report")]
|
||||||
|
Get {
|
||||||
|
#[arg(long = "report")]
|
||||||
|
#[arg(short = 'r')]
|
||||||
|
#[arg(help = "Report id whose data should be retrieved")]
|
||||||
|
report_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "format")]
|
||||||
|
#[arg(short = 'f')]
|
||||||
|
#[arg(help = "Format of the report data to be retrieved")]
|
||||||
|
data_format: Option<DataFormatArg>,
|
||||||
|
|
||||||
|
#[arg(long = "type")]
|
||||||
|
#[arg(short = 't')]
|
||||||
|
#[arg(help = "Type of the report data to be retrieved")]
|
||||||
|
data_type: Option<ReportDataTypeArg>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(about = "List the reports available in an app or survey")]
|
||||||
|
List {
|
||||||
|
#[arg(long = "app")]
|
||||||
|
#[arg(short = 'a')]
|
||||||
|
#[arg(help = "The id of the app whose reports should be listed")]
|
||||||
|
app_id: i32,
|
||||||
|
|
||||||
|
#[command(flatten)]
|
||||||
|
paging: PagingArgs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone, Args, Default)]
|
#[derive(Debug, PartialEq, Clone, Args, Default)]
|
||||||
pub struct PagingArgs {
|
pub struct PagingArgs {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -236,6 +415,12 @@ pub enum DataFormatArg {
|
|||||||
Formatted,
|
Formatted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, Copy, ValueEnum)]
|
||||||
|
pub enum ReportDataTypeArg {
|
||||||
|
ReportData,
|
||||||
|
ChartData,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_cli_from<I, T>(args: I) -> crate::error::CliResult<Cli>
|
pub fn parse_cli_from<I, T>(args: I) -> crate::error::CliResult<Cli>
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = T>,
|
I: IntoIterator<Item = T>,
|
||||||
@@ -263,6 +448,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Ping,
|
command: Command::Ping,
|
||||||
})
|
})
|
||||||
@@ -278,6 +464,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Apps {
|
command: Command::Apps {
|
||||||
command: AppsCommand::List(PagingArgs::default()),
|
command: AppsCommand::List(PagingArgs::default()),
|
||||||
@@ -303,6 +490,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Apps {
|
command: Command::Apps {
|
||||||
command: AppsCommand::List(PagingArgs {
|
command: AppsCommand::List(PagingArgs {
|
||||||
@@ -316,13 +504,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_apps_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_apps_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "apps", "get", "--app-id", "123"]);
|
let result = parse_cli_from(["test", "apps", "get", "--app", "123"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Apps {
|
command: Command::Apps {
|
||||||
command: AppsCommand::Get { app_id: 123 },
|
command: AppsCommand::Get { app_id: 123 },
|
||||||
@@ -333,13 +522,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_apps_get_short_flag_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_apps_get_short_flag_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "apps", "get", "-i", "123"]);
|
let result = parse_cli_from(["test", "apps", "get", "-a", "123"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Apps {
|
command: Command::Apps {
|
||||||
command: AppsCommand::Get { app_id: 123 },
|
command: AppsCommand::Get { app_id: 123 },
|
||||||
@@ -359,13 +549,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_apps_batch_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_apps_batch_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "apps", "batch-get", "--ids", "1,2,3"]);
|
let result = parse_cli_from(["test", "apps", "batch-get", "--apps", "1,2,3"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Apps {
|
command: Command::Apps {
|
||||||
command: AppsCommand::BatchGet { ids: vec![1, 2, 3] },
|
command: AppsCommand::BatchGet { ids: vec![1, 2, 3] },
|
||||||
@@ -384,6 +575,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Apps {
|
command: Command::Apps {
|
||||||
command: AppsCommand::BatchGet { ids: vec![] },
|
command: AppsCommand::BatchGet { ids: vec![] },
|
||||||
@@ -395,7 +587,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_apps_batch_get_invalid_id_format_it_should_return_usage_error()
|
fn parse_cli_from_when_called_with_apps_batch_get_invalid_id_format_it_should_return_usage_error()
|
||||||
{
|
{
|
||||||
let result = parse_cli_from(["test", "apps", "batch-get", "--ids", "invalid"]);
|
let result = parse_cli_from(["test", "apps", "batch-get", "--apps", "invalid"]);
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
@@ -448,13 +640,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_fields_list_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_fields_list_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "fields", "list", "--app-id", "10"]);
|
let result = parse_cli_from(["test", "fields", "list", "--app", "10"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Fields {
|
command: Command::Fields {
|
||||||
command: FieldsCommand::List {
|
command: FieldsCommand::List {
|
||||||
@@ -472,7 +665,7 @@ mod tests {
|
|||||||
"test",
|
"test",
|
||||||
"fields",
|
"fields",
|
||||||
"list",
|
"list",
|
||||||
"-i",
|
"-a",
|
||||||
"10",
|
"10",
|
||||||
"--page-number",
|
"--page-number",
|
||||||
"2",
|
"2",
|
||||||
@@ -485,6 +678,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Fields {
|
command: Command::Fields {
|
||||||
command: FieldsCommand::List {
|
command: FieldsCommand::List {
|
||||||
@@ -510,13 +704,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_fields_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_fields_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "fields", "get", "--field-id", "123"]);
|
let result = parse_cli_from(["test", "fields", "get", "--field", "123"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Fields {
|
command: Command::Fields {
|
||||||
command: FieldsCommand::Get { field_id: 123 },
|
command: FieldsCommand::Get { field_id: 123 },
|
||||||
@@ -527,13 +722,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_fields_get_short_flag_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_fields_get_short_flag_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "fields", "get", "-i", "123"]);
|
let result = parse_cli_from(["test", "fields", "get", "-f", "123"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Fields {
|
command: Command::Fields {
|
||||||
command: FieldsCommand::Get { field_id: 123 },
|
command: FieldsCommand::Get { field_id: 123 },
|
||||||
@@ -553,13 +749,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_fields_batch_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_fields_batch_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "fields", "batch-get", "--ids", "1,2,3"]);
|
let result = parse_cli_from(["test", "fields", "batch-get", "--fields", "1,2,3"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Fields {
|
command: Command::Fields {
|
||||||
command: FieldsCommand::BatchGet { ids: vec![1, 2, 3] },
|
command: FieldsCommand::BatchGet { ids: vec![1, 2, 3] },
|
||||||
@@ -578,6 +775,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Fields {
|
command: Command::Fields {
|
||||||
command: FieldsCommand::BatchGet { ids: vec![] },
|
command: FieldsCommand::BatchGet { ids: vec![] },
|
||||||
@@ -589,7 +787,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_fields_batch_get_invalid_id_format_it_should_return_usage_error()
|
fn parse_cli_from_when_called_with_fields_batch_get_invalid_id_format_it_should_return_usage_error()
|
||||||
{
|
{
|
||||||
let result = parse_cli_from(["test", "fields", "batch-get", "--ids", "invalid"]);
|
let result = parse_cli_from(["test", "fields", "batch-get", "--fields", "invalid"]);
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
@@ -625,6 +823,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Records {
|
command: Command::Records {
|
||||||
command: RecordsCommand::List {
|
command: RecordsCommand::List {
|
||||||
@@ -661,6 +860,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Records {
|
command: Command::Records {
|
||||||
command: RecordsCommand::List {
|
command: RecordsCommand::List {
|
||||||
@@ -680,7 +880,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_records_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_records_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from([
|
let result = parse_cli_from([
|
||||||
"test", "records", "get", "-a", "1", "-r", "10", "-f", "100", "-d", "formatted",
|
"test",
|
||||||
|
"records",
|
||||||
|
"get",
|
||||||
|
"-a",
|
||||||
|
"1",
|
||||||
|
"-r",
|
||||||
|
"10",
|
||||||
|
"-f",
|
||||||
|
"100",
|
||||||
|
"-d",
|
||||||
|
"formatted",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -688,6 +898,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Records {
|
command: Command::Records {
|
||||||
command: RecordsCommand::Get {
|
command: RecordsCommand::Get {
|
||||||
@@ -703,15 +914,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_records_save_json_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_records_save_json_it_should_return_cli() {
|
||||||
let result = parse_cli_from([
|
let result = parse_cli_from(["test", "records", "save", "--json", r#"{"appId":1}"#]);
|
||||||
"test", "records", "save", "--json", r#"{"appId":1}"#,
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Records {
|
command: Command::Records {
|
||||||
command: RecordsCommand::Save {
|
command: RecordsCommand::Save {
|
||||||
@@ -735,6 +945,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Records {
|
command: Command::Records {
|
||||||
command: RecordsCommand::Delete {
|
command: RecordsCommand::Delete {
|
||||||
@@ -748,15 +959,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_records_batch_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_records_batch_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from([
|
let result = parse_cli_from(["test", "records", "batch-get", "-f", "input.json"]);
|
||||||
"test", "records", "batch-get", "-f", "input.json",
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Records {
|
command: Command::Records {
|
||||||
command: RecordsCommand::BatchGet {
|
command: RecordsCommand::BatchGet {
|
||||||
@@ -773,15 +983,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_records_query_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_records_query_it_should_return_cli() {
|
||||||
let result = parse_cli_from([
|
let result = parse_cli_from(["test", "records", "query", "--stdin", "-n", "1", "-s", "50"]);
|
||||||
"test", "records", "query", "--stdin", "-n", "1", "-s", "50",
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Records {
|
command: Command::Records {
|
||||||
command: RecordsCommand::Query {
|
command: RecordsCommand::Query {
|
||||||
@@ -803,7 +1012,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_records_batch_delete_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_records_batch_delete_it_should_return_cli() {
|
||||||
let result = parse_cli_from([
|
let result = parse_cli_from([
|
||||||
"test", "records", "batch-delete", "-j", r#"{"appId":1,"recordIds":[10]}"#,
|
"test",
|
||||||
|
"records",
|
||||||
|
"batch-delete",
|
||||||
|
"-j",
|
||||||
|
r#"{"appId":1,"recordIds":[10]}"#,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -811,6 +1024,7 @@ mod tests {
|
|||||||
Ok(Cli {
|
Ok(Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Records {
|
command: Command::Records {
|
||||||
command: RecordsCommand::BatchDelete {
|
command: RecordsCommand::BatchDelete {
|
||||||
@@ -833,4 +1047,321 @@ mod tests {
|
|||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
assert_eq!(err.code, 2);
|
assert_eq!(err.code, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_files_info_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test", "files", "info", "--record", "1", "--field", "2", "--file", "3",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Files {
|
||||||
|
command: FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_files_get_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test", "files", "get", "-r", "1", "-f", "2", "-l", "3", "-o", "out.bin",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Files {
|
||||||
|
command: FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
output: Some(std::path::PathBuf::from("out.bin")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_files_upload_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test",
|
||||||
|
"files",
|
||||||
|
"upload",
|
||||||
|
"-r",
|
||||||
|
"1",
|
||||||
|
"-f",
|
||||||
|
"2",
|
||||||
|
"-i",
|
||||||
|
"doc.pdf",
|
||||||
|
"-n",
|
||||||
|
"renamed.pdf",
|
||||||
|
"-t",
|
||||||
|
"application/pdf",
|
||||||
|
"--notes",
|
||||||
|
"test notes",
|
||||||
|
"--modified-date",
|
||||||
|
"2026-01-01T00:00:00Z",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Files {
|
||||||
|
command: FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(std::path::PathBuf::from("doc.pdf")),
|
||||||
|
stdin: false,
|
||||||
|
file_name: Some("renamed.pdf".to_string()),
|
||||||
|
content_type: Some("application/pdf".to_string()),
|
||||||
|
notes: Some("test notes".to_string()),
|
||||||
|
modified_date: Some("2026-01-01T00:00:00Z".to_string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_files_delete_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test", "files", "delete", "--record", "10", "--field", "20", "--file", "30",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Files {
|
||||||
|
command: FilesCommand::Delete {
|
||||||
|
record_id: 10,
|
||||||
|
field_id: 20,
|
||||||
|
file_id: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_files_without_subcommand_it_should_return_usage_error() {
|
||||||
|
let result = parse_cli_from(["test", "files"]);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_lists_save_item_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test",
|
||||||
|
"lists",
|
||||||
|
"save-item",
|
||||||
|
"-l",
|
||||||
|
"1",
|
||||||
|
"-j",
|
||||||
|
r#"{"name":"Item 1"}"#,
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Lists {
|
||||||
|
command: ListsCommand::SaveItem {
|
||||||
|
list_id: 1,
|
||||||
|
body: BodySource {
|
||||||
|
json: Some(r#"{"name":"Item 1"}"#.to_string()),
|
||||||
|
file: None,
|
||||||
|
stdin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_lists_delete_item_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test",
|
||||||
|
"lists",
|
||||||
|
"delete-item",
|
||||||
|
"-l",
|
||||||
|
"10",
|
||||||
|
"-i",
|
||||||
|
"d3b07384-d113-40e1-a20c-55c3c0a4e70e",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Lists {
|
||||||
|
command: ListsCommand::DeleteItem {
|
||||||
|
list_id: 10,
|
||||||
|
item_id: Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_lists_without_subcommand_it_should_return_usage_error() {
|
||||||
|
let result = parse_cli_from(["test", "lists"]);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_reports_get_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from(["test", "reports", "get", "-r", "123"]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Reports {
|
||||||
|
command: ReportsCommand::Get {
|
||||||
|
report_id: 123,
|
||||||
|
data_format: None,
|
||||||
|
data_type: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_reports_get_with_options_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test",
|
||||||
|
"reports",
|
||||||
|
"get",
|
||||||
|
"--report",
|
||||||
|
"456",
|
||||||
|
"--format",
|
||||||
|
"formatted",
|
||||||
|
"--type",
|
||||||
|
"chart-data",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Reports {
|
||||||
|
command: ReportsCommand::Get {
|
||||||
|
report_id: 456,
|
||||||
|
data_format: Some(DataFormatArg::Formatted),
|
||||||
|
data_type: Some(ReportDataTypeArg::ChartData),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_reports_list_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from(["test", "reports", "list", "-a", "10", "-n", "2", "-s", "25"]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Reports {
|
||||||
|
command: ReportsCommand::List {
|
||||||
|
app_id: 10,
|
||||||
|
paging: PagingArgs {
|
||||||
|
page_number: Some(2),
|
||||||
|
page_size: Some(25),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_reports_without_subcommand_it_should_return_usage_error() {
|
||||||
|
let result = parse_cli_from(["test", "reports"]);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_config_flag_it_should_return_cli_with_config_path() {
|
||||||
|
let result = parse_cli_from(["test", "--config", "custom/config.json", "ping"]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: Some(PathBuf::from("custom/config.json")),
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Ping,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_short_config_flag_it_should_return_cli_with_config_path() {
|
||||||
|
let result = parse_cli_from(["test", "-c", "custom/config.json", "ping"]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: Some(PathBuf::from("custom/config.json")),
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Ping,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+326
-3
@@ -1,8 +1,11 @@
|
|||||||
use onspring::{
|
use onspring::{
|
||||||
App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse, DataFormat, Field,
|
App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse,
|
||||||
OnspringClient, OnspringError, PagedResponse, PagingRequest, QueryRecordsRequest, Record,
|
CreatedWithIdResponse, DataFormat, Field, FileInfo, FileResponse, OnspringClient, OnspringError,
|
||||||
SaveRecordRequest, SaveRecordResponse,
|
PagedResponse, PagingRequest, QueryRecordsRequest, Record, ReportData, ReportDataType,
|
||||||
|
ReportInfo, SaveFileRequest, SaveListItemRequest, SaveListItemResponse, SaveRecordRequest,
|
||||||
|
SaveRecordResponse,
|
||||||
};
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub trait OnspringRunner {
|
pub trait OnspringRunner {
|
||||||
fn ping(&self) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
|
fn ping(&self) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
|
||||||
@@ -80,6 +83,57 @@ pub trait OnspringRunner {
|
|||||||
&self,
|
&self,
|
||||||
request: BatchDeleteRecordsRequest,
|
request: BatchDeleteRecordsRequest,
|
||||||
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
|
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn get_file_info(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> impl std::future::Future<Output = Result<FileInfo, OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn get_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> impl std::future::Future<Output = Result<FileResponse, OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn upload_file(
|
||||||
|
&self,
|
||||||
|
request: SaveFileRequest,
|
||||||
|
) -> impl std::future::Future<Output = Result<CreatedWithIdResponse, OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn delete_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn save_list_item(
|
||||||
|
&self,
|
||||||
|
list_id: i32,
|
||||||
|
request: SaveListItemRequest,
|
||||||
|
) -> impl std::future::Future<Output = Result<SaveListItemResponse, OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn delete_list_item(
|
||||||
|
&self,
|
||||||
|
list_id: i32,
|
||||||
|
item_id: Uuid,
|
||||||
|
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn get_report(
|
||||||
|
&self,
|
||||||
|
report_id: i32,
|
||||||
|
data_format: Option<DataFormat>,
|
||||||
|
data_type: Option<ReportDataType>,
|
||||||
|
) -> impl std::future::Future<Output = Result<ReportData, OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn list_reports(
|
||||||
|
&self,
|
||||||
|
app_id: i32,
|
||||||
|
paging: Option<PagingRequest>,
|
||||||
|
) -> impl std::future::Future<Output = Result<PagedResponse<ReportInfo>, OnspringError>> + Send;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OnspringRunner for OnspringClient {
|
impl OnspringRunner for OnspringClient {
|
||||||
@@ -170,6 +224,69 @@ impl OnspringRunner for OnspringClient {
|
|||||||
) -> Result<(), OnspringError> {
|
) -> Result<(), OnspringError> {
|
||||||
self.batch_delete_records(request).await
|
self.batch_delete_records(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_file_info(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<FileInfo, OnspringError> {
|
||||||
|
self.get_file_info(record_id, field_id, file_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<FileResponse, OnspringError> {
|
||||||
|
self.get_file(record_id, field_id, file_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_file(
|
||||||
|
&self,
|
||||||
|
request: SaveFileRequest,
|
||||||
|
) -> Result<CreatedWithIdResponse, OnspringError> {
|
||||||
|
self.upload_file(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<(), OnspringError> {
|
||||||
|
self.delete_file(record_id, field_id, file_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_list_item(
|
||||||
|
&self,
|
||||||
|
list_id: i32,
|
||||||
|
request: SaveListItemRequest,
|
||||||
|
) -> Result<SaveListItemResponse, OnspringError> {
|
||||||
|
self.save_list_item(list_id, request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_list_item(&self, list_id: i32, item_id: Uuid) -> Result<(), OnspringError> {
|
||||||
|
self.delete_list_item(list_id, item_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_report(
|
||||||
|
&self,
|
||||||
|
report_id: i32,
|
||||||
|
data_format: Option<DataFormat>,
|
||||||
|
data_type: Option<ReportDataType>,
|
||||||
|
) -> Result<ReportData, OnspringError> {
|
||||||
|
self.get_report(report_id, data_format, data_type).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_reports(
|
||||||
|
&self,
|
||||||
|
app_id: i32,
|
||||||
|
paging: Option<PagingRequest>,
|
||||||
|
) -> Result<PagedResponse<ReportInfo>, OnspringError> {
|
||||||
|
self.list_reports(app_id, paging).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -219,6 +336,44 @@ pub mod testing {
|
|||||||
pub query_records_request: Mutex<Option<QueryRecordsRequest>>,
|
pub query_records_request: Mutex<Option<QueryRecordsRequest>>,
|
||||||
pub query_records_paging: Mutex<Option<Option<PagingRequest>>>,
|
pub query_records_paging: Mutex<Option<Option<PagingRequest>>>,
|
||||||
pub batch_delete_records_request: Mutex<Option<BatchDeleteRecordsRequest>>,
|
pub batch_delete_records_request: Mutex<Option<BatchDeleteRecordsRequest>>,
|
||||||
|
|
||||||
|
pub get_file_info_result: Result<FileInfo, OnspringError>,
|
||||||
|
pub get_file_result: Result<FileResponse, OnspringError>,
|
||||||
|
pub upload_file_result: Result<CreatedWithIdResponse, OnspringError>,
|
||||||
|
pub delete_file_result: Result<(), OnspringError>,
|
||||||
|
|
||||||
|
pub get_file_info_record_id: Mutex<Option<i32>>,
|
||||||
|
pub get_file_info_field_id: Mutex<Option<i32>>,
|
||||||
|
pub get_file_info_file_id: Mutex<Option<i32>>,
|
||||||
|
|
||||||
|
pub get_file_record_id: Mutex<Option<i32>>,
|
||||||
|
pub get_file_field_id: Mutex<Option<i32>>,
|
||||||
|
pub get_file_file_id: Mutex<Option<i32>>,
|
||||||
|
|
||||||
|
pub upload_file_request: Mutex<Option<SaveFileRequest>>,
|
||||||
|
|
||||||
|
pub delete_file_record_id: Mutex<Option<i32>>,
|
||||||
|
pub delete_file_field_id: Mutex<Option<i32>>,
|
||||||
|
pub delete_file_file_id: Mutex<Option<i32>>,
|
||||||
|
|
||||||
|
pub save_list_item_result: Result<SaveListItemResponse, OnspringError>,
|
||||||
|
pub delete_list_item_result: Result<(), OnspringError>,
|
||||||
|
|
||||||
|
pub save_list_item_list_id: Mutex<Option<i32>>,
|
||||||
|
pub save_list_item_request: Mutex<Option<SaveListItemRequest>>,
|
||||||
|
|
||||||
|
pub delete_list_item_list_id: Mutex<Option<i32>>,
|
||||||
|
pub delete_list_item_item_id: Mutex<Option<Uuid>>,
|
||||||
|
|
||||||
|
pub get_report_result: Result<ReportData, OnspringError>,
|
||||||
|
pub list_reports_result: Result<PagedResponse<ReportInfo>, OnspringError>,
|
||||||
|
|
||||||
|
pub get_report_report_id: Mutex<Option<i32>>,
|
||||||
|
pub get_report_data_format: Mutex<Option<Option<DataFormat>>>,
|
||||||
|
pub get_report_data_type: Mutex<Option<Option<ReportDataType>>>,
|
||||||
|
|
||||||
|
pub list_reports_app_id: Mutex<Option<i32>>,
|
||||||
|
pub list_reports_paging: Mutex<Option<Option<PagingRequest>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MockClient {
|
impl Default for MockClient {
|
||||||
@@ -322,6 +477,68 @@ pub mod testing {
|
|||||||
query_records_request: Mutex::new(None),
|
query_records_request: Mutex::new(None),
|
||||||
query_records_paging: Mutex::new(None),
|
query_records_paging: Mutex::new(None),
|
||||||
batch_delete_records_request: Mutex::new(None),
|
batch_delete_records_request: Mutex::new(None),
|
||||||
|
|
||||||
|
get_file_info_result: Ok(FileInfo {
|
||||||
|
file_type: None,
|
||||||
|
content_type: None,
|
||||||
|
name: None,
|
||||||
|
created_date: None,
|
||||||
|
modified_date: None,
|
||||||
|
owner: None,
|
||||||
|
notes: None,
|
||||||
|
file_href: None,
|
||||||
|
}),
|
||||||
|
get_file_result: Ok(FileResponse {
|
||||||
|
content_type: None,
|
||||||
|
file_name: None,
|
||||||
|
data: bytes::Bytes::new(),
|
||||||
|
}),
|
||||||
|
upload_file_result: Ok(CreatedWithIdResponse { id: 1 }),
|
||||||
|
delete_file_result: Ok(()),
|
||||||
|
|
||||||
|
get_file_info_record_id: Mutex::new(None),
|
||||||
|
get_file_info_field_id: Mutex::new(None),
|
||||||
|
get_file_info_file_id: Mutex::new(None),
|
||||||
|
|
||||||
|
get_file_record_id: Mutex::new(None),
|
||||||
|
get_file_field_id: Mutex::new(None),
|
||||||
|
get_file_file_id: Mutex::new(None),
|
||||||
|
|
||||||
|
upload_file_request: Mutex::new(None),
|
||||||
|
|
||||||
|
delete_file_record_id: Mutex::new(None),
|
||||||
|
delete_file_field_id: Mutex::new(None),
|
||||||
|
delete_file_file_id: Mutex::new(None),
|
||||||
|
|
||||||
|
save_list_item_result: Ok(SaveListItemResponse {
|
||||||
|
id: Uuid::nil(),
|
||||||
|
}),
|
||||||
|
delete_list_item_result: Ok(()),
|
||||||
|
|
||||||
|
save_list_item_list_id: Mutex::new(None),
|
||||||
|
save_list_item_request: Mutex::new(None),
|
||||||
|
|
||||||
|
delete_list_item_list_id: Mutex::new(None),
|
||||||
|
delete_list_item_item_id: Mutex::new(None),
|
||||||
|
|
||||||
|
get_report_result: Ok(ReportData {
|
||||||
|
columns: None,
|
||||||
|
rows: None,
|
||||||
|
}),
|
||||||
|
list_reports_result: Ok(PagedResponse {
|
||||||
|
page_number: None,
|
||||||
|
page_size: None,
|
||||||
|
total_pages: None,
|
||||||
|
total_records: None,
|
||||||
|
items: None,
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_report_report_id: Mutex::new(None),
|
||||||
|
get_report_data_format: Mutex::new(None),
|
||||||
|
get_report_data_type: Mutex::new(None),
|
||||||
|
|
||||||
|
list_reports_app_id: Mutex::new(None),
|
||||||
|
list_reports_paging: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,5 +713,111 @@ pub mod testing {
|
|||||||
Err(err) => Err(clone_onspring_error(err)),
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_file_info(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<FileInfo, OnspringError> {
|
||||||
|
*self.get_file_info_record_id.lock().unwrap() = Some(record_id);
|
||||||
|
*self.get_file_info_field_id.lock().unwrap() = Some(field_id);
|
||||||
|
*self.get_file_info_file_id.lock().unwrap() = Some(file_id);
|
||||||
|
match &self.get_file_info_result {
|
||||||
|
Ok(res) => Ok(res.clone()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<FileResponse, OnspringError> {
|
||||||
|
*self.get_file_record_id.lock().unwrap() = Some(record_id);
|
||||||
|
*self.get_file_field_id.lock().unwrap() = Some(field_id);
|
||||||
|
*self.get_file_file_id.lock().unwrap() = Some(file_id);
|
||||||
|
match &self.get_file_result {
|
||||||
|
Ok(res) => Ok(res.clone()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_file(
|
||||||
|
&self,
|
||||||
|
request: SaveFileRequest,
|
||||||
|
) -> Result<CreatedWithIdResponse, OnspringError> {
|
||||||
|
*self.upload_file_request.lock().unwrap() = Some(request);
|
||||||
|
match &self.upload_file_result {
|
||||||
|
Ok(res) => Ok(res.clone()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<(), OnspringError> {
|
||||||
|
*self.delete_file_record_id.lock().unwrap() = Some(record_id);
|
||||||
|
*self.delete_file_field_id.lock().unwrap() = Some(field_id);
|
||||||
|
*self.delete_file_file_id.lock().unwrap() = Some(file_id);
|
||||||
|
match &self.delete_file_result {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_list_item(
|
||||||
|
&self,
|
||||||
|
list_id: i32,
|
||||||
|
request: SaveListItemRequest,
|
||||||
|
) -> Result<SaveListItemResponse, OnspringError> {
|
||||||
|
*self.save_list_item_list_id.lock().unwrap() = Some(list_id);
|
||||||
|
*self.save_list_item_request.lock().unwrap() = Some(request);
|
||||||
|
match &self.save_list_item_result {
|
||||||
|
Ok(res) => Ok(res.clone()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_list_item(&self, list_id: i32, item_id: Uuid) -> Result<(), OnspringError> {
|
||||||
|
*self.delete_list_item_list_id.lock().unwrap() = Some(list_id);
|
||||||
|
*self.delete_list_item_item_id.lock().unwrap() = Some(item_id);
|
||||||
|
match &self.delete_list_item_result {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_report(
|
||||||
|
&self,
|
||||||
|
report_id: i32,
|
||||||
|
data_format: Option<DataFormat>,
|
||||||
|
data_type: Option<ReportDataType>,
|
||||||
|
) -> Result<ReportData, OnspringError> {
|
||||||
|
*self.get_report_report_id.lock().unwrap() = Some(report_id);
|
||||||
|
*self.get_report_data_format.lock().unwrap() = Some(data_format);
|
||||||
|
*self.get_report_data_type.lock().unwrap() = Some(data_type);
|
||||||
|
match &self.get_report_result {
|
||||||
|
Ok(res) => Ok(res.clone()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_reports(
|
||||||
|
&self,
|
||||||
|
app_id: i32,
|
||||||
|
paging: Option<PagingRequest>,
|
||||||
|
) -> Result<PagedResponse<ReportInfo>, OnspringError> {
|
||||||
|
*self.list_reports_app_id.lock().unwrap() = Some(app_id);
|
||||||
|
*self.list_reports_paging.lock().unwrap() = Some(paging);
|
||||||
|
match &self.list_reports_result {
|
||||||
|
Ok(res) => Ok(res.clone()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,965 @@
|
|||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use onspring::SaveFileRequest;
|
||||||
|
|
||||||
|
use crate::cli::FilesCommand;
|
||||||
|
use crate::client::OnspringRunner;
|
||||||
|
use crate::error::{CliError, CliResult};
|
||||||
|
use crate::output::{CreatedWithIdResponseOutput, FileInfoOutput, SuccessResponse, write_json};
|
||||||
|
use crate::validation::validate_positive_i32;
|
||||||
|
|
||||||
|
pub async fn handle<C: OnspringRunner, W: Write>(
|
||||||
|
command: &FilesCommand,
|
||||||
|
client: &C,
|
||||||
|
writer: &mut W,
|
||||||
|
pretty: bool,
|
||||||
|
) -> CliResult<()> {
|
||||||
|
handle_with_reader(command, client, writer, std::io::stdin(), pretty).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_with_reader<C: OnspringRunner, W: Write, R: Read>(
|
||||||
|
command: &FilesCommand,
|
||||||
|
client: &C,
|
||||||
|
writer: &mut W,
|
||||||
|
mut reader: R,
|
||||||
|
pretty: bool,
|
||||||
|
) -> CliResult<()> {
|
||||||
|
match command {
|
||||||
|
FilesCommand::Info {
|
||||||
|
record_id,
|
||||||
|
field_id,
|
||||||
|
file_id,
|
||||||
|
} => {
|
||||||
|
validate_positive_i32(*record_id, "record_id")?;
|
||||||
|
validate_positive_i32(*field_id, "field_id")?;
|
||||||
|
validate_positive_i32(*file_id, "file_id")?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get_file_info(*record_id, *field_id, *file_id)
|
||||||
|
.await?;
|
||||||
|
let output: FileInfoOutput = response.into();
|
||||||
|
write_json(writer, &output, pretty)?;
|
||||||
|
}
|
||||||
|
FilesCommand::Get {
|
||||||
|
record_id,
|
||||||
|
field_id,
|
||||||
|
file_id,
|
||||||
|
output,
|
||||||
|
} => {
|
||||||
|
validate_positive_i32(*record_id, "record_id")?;
|
||||||
|
validate_positive_i32(*field_id, "field_id")?;
|
||||||
|
validate_positive_i32(*file_id, "file_id")?;
|
||||||
|
|
||||||
|
let response = client.get_file(*record_id, *field_id, *file_id).await?;
|
||||||
|
|
||||||
|
if let Some(output_path) = output {
|
||||||
|
std::fs::write(output_path, &response.data).map_err(|e| {
|
||||||
|
CliError::runtime(format!(
|
||||||
|
"Failed to write file to '{}': {e}",
|
||||||
|
output_path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
writer
|
||||||
|
.write_all(&response.data)
|
||||||
|
.map_err(|_| CliError::runtime("Failed to write output"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FilesCommand::Upload {
|
||||||
|
record_id,
|
||||||
|
field_id,
|
||||||
|
input,
|
||||||
|
stdin,
|
||||||
|
file_name,
|
||||||
|
content_type,
|
||||||
|
notes,
|
||||||
|
modified_date,
|
||||||
|
} => {
|
||||||
|
validate_positive_i32(*record_id, "record_id")?;
|
||||||
|
validate_positive_i32(*field_id, "field_id")?;
|
||||||
|
|
||||||
|
if input.is_none() && !*stdin {
|
||||||
|
return Err(CliError::usage(
|
||||||
|
"A file source is required. Provide --input or --stdin.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.is_some() && *stdin {
|
||||||
|
return Err(CliError::usage(
|
||||||
|
"Only one file source may be specified (--input or --stdin).",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (file_bytes, resolved_file_name) = if let Some(path) = input {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| {
|
||||||
|
CliError::usage(format!("Failed to read file '{}': {e}", path.display()))
|
||||||
|
})?;
|
||||||
|
let default_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(String::from);
|
||||||
|
let name = file_name.clone().or(default_name).ok_or_else(|| {
|
||||||
|
CliError::usage("A file name is required. Provide --name.")
|
||||||
|
})?;
|
||||||
|
(bytes, name)
|
||||||
|
} else {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut buffer)
|
||||||
|
.map_err(|e| CliError::runtime(format!("Failed to read from stdin: {e}")))?;
|
||||||
|
let name = file_name
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| CliError::usage("A file name is required when reading from stdin. Provide --name."))?;
|
||||||
|
(buffer, name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved_content_type = match content_type {
|
||||||
|
Some(ct) => ct.clone(),
|
||||||
|
None => mime_guess::from_path(&resolved_file_name)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed_modified_date = match modified_date {
|
||||||
|
Some(date_str) => Some(DateTime::parse_from_rfc3339(date_str).map_err(|e| {
|
||||||
|
CliError::usage(format!(
|
||||||
|
"Invalid modified_date '{date_str}'. Expected RFC3339 format: {e}"
|
||||||
|
))
|
||||||
|
})?.with_timezone(&Utc)),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = SaveFileRequest {
|
||||||
|
record_id: *record_id,
|
||||||
|
field_id: *field_id,
|
||||||
|
notes: notes.clone(),
|
||||||
|
modified_date: parsed_modified_date,
|
||||||
|
file_name: resolved_file_name,
|
||||||
|
file_data: file_bytes,
|
||||||
|
content_type: resolved_content_type,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.upload_file(request).await?;
|
||||||
|
let output: CreatedWithIdResponseOutput = response.into();
|
||||||
|
write_json(writer, &output, pretty)?;
|
||||||
|
}
|
||||||
|
FilesCommand::Delete {
|
||||||
|
record_id,
|
||||||
|
field_id,
|
||||||
|
file_id,
|
||||||
|
} => {
|
||||||
|
validate_positive_i32(*record_id, "record_id")?;
|
||||||
|
validate_positive_i32(*field_id, "field_id")?;
|
||||||
|
validate_positive_i32(*file_id, "file_id")?;
|
||||||
|
|
||||||
|
client.delete_file(*record_id, *field_id, *file_id).await?;
|
||||||
|
write_json(writer, &SuccessResponse { ok: true }, pretty)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
use onspring::{CreatedWithIdResponse, FileInfo, FileResponse, OnspringError};
|
||||||
|
|
||||||
|
use crate::client::testing::MockClient;
|
||||||
|
use crate::error::CliError;
|
||||||
|
|
||||||
|
// --- Info tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_succeeds_it_should_write_file_info_output() {
|
||||||
|
let created = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
|
||||||
|
let modified = Utc.with_ymd_and_hms(2026, 1, 16, 12, 0, 0).unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_info_result: Ok(FileInfo {
|
||||||
|
file_type: Some("Attachment".to_string()),
|
||||||
|
content_type: Some("application/pdf".to_string()),
|
||||||
|
name: Some("test.pdf".to_string()),
|
||||||
|
created_date: Some(created),
|
||||||
|
modified_date: Some(modified),
|
||||||
|
owner: Some("User 1".to_string()),
|
||||||
|
notes: Some("Some notes".to_string()),
|
||||||
|
file_href: Some("https://api.onspring.com/files/3/file".to_string()),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
written.trim(),
|
||||||
|
r#"{"type":"Attachment","contentType":"application/pdf","name":"test.pdf","createdDate":"2026-01-15T10:00:00Z","modifiedDate":"2026-01-16T12:00:00Z","owner":"User 1","notes":"Some notes","fileHref":"https://api.onspring.com/files/3/file"}"#
|
||||||
|
);
|
||||||
|
assert_eq!(*mock_client.get_file_info_record_id.lock().unwrap(), Some(1));
|
||||||
|
assert_eq!(*mock_client.get_file_info_field_id.lock().unwrap(), Some(2));
|
||||||
|
assert_eq!(*mock_client.get_file_info_file_id.lock().unwrap(), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_succeeds_pretty_it_should_write_pretty_json() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_info_result: Ok(FileInfo {
|
||||||
|
file_type: Some("Attachment".to_string()),
|
||||||
|
content_type: Some("text/plain".to_string()),
|
||||||
|
name: Some("note.txt".to_string()),
|
||||||
|
created_date: None,
|
||||||
|
modified_date: None,
|
||||||
|
owner: None,
|
||||||
|
notes: None,
|
||||||
|
file_href: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, true).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
let expected = concat!(
|
||||||
|
"{\n",
|
||||||
|
" \"type\": \"Attachment\",\n",
|
||||||
|
" \"contentType\": \"text/plain\",\n",
|
||||||
|
" \"name\": \"note.txt\",\n",
|
||||||
|
" \"createdDate\": null,\n",
|
||||||
|
" \"modifiedDate\": null,\n",
|
||||||
|
" \"owner\": null,\n",
|
||||||
|
" \"notes\": null,\n",
|
||||||
|
" \"fileHref\": null\n",
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
assert_eq!(written.trim(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_with_invalid_record_id_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 0,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("record_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_with_invalid_field_id_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: -1,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("field_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_with_invalid_file_id_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 0,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("file_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_info_result: Err(OnspringError::Api {
|
||||||
|
status_code: 404,
|
||||||
|
message: "File not found".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::runtime("API request failed (404): File not found"))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Get tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_get_without_output_flag_it_should_write_bytes_to_writer() {
|
||||||
|
let file_bytes = b"Hello, World!";
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_result: Ok(FileResponse {
|
||||||
|
content_type: Some("text/plain".to_string()),
|
||||||
|
file_name: Some("hello.txt".to_string()),
|
||||||
|
data: Bytes::from_static(file_bytes),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 10,
|
||||||
|
field_id: 20,
|
||||||
|
file_id: 30,
|
||||||
|
output: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
assert_eq!(buffer, file_bytes);
|
||||||
|
assert_eq!(*mock_client.get_file_record_id.lock().unwrap(), Some(10));
|
||||||
|
assert_eq!(*mock_client.get_file_field_id.lock().unwrap(), Some(20));
|
||||||
|
assert_eq!(*mock_client.get_file_file_id.lock().unwrap(), Some(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_get_with_output_flag_it_should_write_to_file() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("downloaded.bin");
|
||||||
|
let file_bytes = vec![1, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_result: Ok(FileResponse {
|
||||||
|
content_type: Some("application/octet-stream".to_string()),
|
||||||
|
file_name: Some("data.bin".to_string()),
|
||||||
|
data: Bytes::from(file_bytes.clone()),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
output: Some(file_path.clone()),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
let read_back = std::fs::read(&file_path).unwrap();
|
||||||
|
assert_eq!(read_back, file_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_get_with_invalid_output_path_it_should_return_runtime_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_result: Ok(FileResponse {
|
||||||
|
content_type: None,
|
||||||
|
file_name: None,
|
||||||
|
data: Bytes::from_static(b"data"),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
output: Some(PathBuf::from("/nonexistent_dir_12345/sub/file.txt")),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 1);
|
||||||
|
assert!(err.message.contains("Failed to write file to"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_get_with_invalid_ids_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 0,
|
||||||
|
field_id: 1,
|
||||||
|
file_id: 1,
|
||||||
|
output: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("record_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 0,
|
||||||
|
file_id: 1,
|
||||||
|
output: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("field_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 1,
|
||||||
|
file_id: 0,
|
||||||
|
output: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("file_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_get_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_result: Err(OnspringError::Api {
|
||||||
|
status_code: 500,
|
||||||
|
message: "Internal error".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
output: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::runtime(
|
||||||
|
"API request failed (500): Internal error"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Upload tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_from_input_file_succeeds() {
|
||||||
|
let temp_file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
std::fs::write(temp_file.path(), b"Upload content").unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
upload_file_result: Ok(CreatedWithIdResponse { id: 99 }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(temp_file.path().to_path_buf()),
|
||||||
|
stdin: false,
|
||||||
|
file_name: Some("custom_name.txt".to_string()),
|
||||||
|
content_type: Some("text/custom".to_string()),
|
||||||
|
notes: Some("Upload note".to_string()),
|
||||||
|
modified_date: Some("2026-03-01T12:00:00Z".to_string()),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
assert_eq!(written.trim(), r#"{"id":99}"#);
|
||||||
|
|
||||||
|
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
|
||||||
|
assert_eq!(req.record_id, 1);
|
||||||
|
assert_eq!(req.field_id, 2);
|
||||||
|
assert_eq!(req.file_name, "custom_name.txt");
|
||||||
|
assert_eq!(req.content_type, "text/custom");
|
||||||
|
assert_eq!(req.file_data, b"Upload content");
|
||||||
|
assert_eq!(req.notes, Some("Upload note".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
req.modified_date,
|
||||||
|
Some(Utc.with_ymd_and_hms(2026, 3, 1, 12, 0, 0).unwrap())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_succeeds_pretty_it_should_write_pretty_json() {
|
||||||
|
let temp_file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
std::fs::write(temp_file.path(), b"content").unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
upload_file_result: Ok(CreatedWithIdResponse { id: 101 }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(temp_file.path().to_path_buf()),
|
||||||
|
stdin: false,
|
||||||
|
file_name: Some("data.txt".to_string()),
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, true).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
let expected = concat!("{\n", " \"id\": 101\n", "}");
|
||||||
|
assert_eq!(written.trim(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_infers_file_name_and_content_type_from_input() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("report.csv");
|
||||||
|
std::fs::write(&file_path, b"a,b,c\n1,2,3").unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
upload_file_result: Ok(CreatedWithIdResponse { id: 50 }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(file_path),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
|
||||||
|
assert_eq!(req.file_name, "report.csv");
|
||||||
|
assert_eq!(req.content_type, "text/csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_reads_from_stdin() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
upload_file_result: Ok(CreatedWithIdResponse { id: 75 }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 5,
|
||||||
|
field_id: 10,
|
||||||
|
input: None,
|
||||||
|
stdin: true,
|
||||||
|
file_name: Some("data.json".to_string()),
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let stdin_bytes = b"{\"hello\":\"world\"}";
|
||||||
|
|
||||||
|
let result = handle_with_reader(
|
||||||
|
&command,
|
||||||
|
&mock_client,
|
||||||
|
&mut buffer,
|
||||||
|
&stdin_bytes[..],
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
|
||||||
|
assert_eq!(req.record_id, 5);
|
||||||
|
assert_eq!(req.field_id, 10);
|
||||||
|
assert_eq!(req.file_name, "data.json");
|
||||||
|
assert_eq!(req.content_type, "application/json");
|
||||||
|
assert_eq!(req.file_data, stdin_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_without_source_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: None,
|
||||||
|
stdin: false,
|
||||||
|
file_name: Some("file.txt".to_string()),
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage(
|
||||||
|
"A file source is required. Provide --input or --stdin."
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_with_both_sources_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(PathBuf::from("test.txt")),
|
||||||
|
stdin: true,
|
||||||
|
file_name: Some("file.txt".to_string()),
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage(
|
||||||
|
"Only one file source may be specified (--input or --stdin)."
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_input_file_not_found_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(PathBuf::from("nonexistent_file_12345.txt")),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 2);
|
||||||
|
assert!(err.message.contains("Failed to read file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_stdin_missing_name_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: None,
|
||||||
|
stdin: true,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle_with_reader(
|
||||||
|
&command,
|
||||||
|
&mock_client,
|
||||||
|
&mut buffer,
|
||||||
|
std::io::empty(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage(
|
||||||
|
"A file name is required when reading from stdin. Provide --name."
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_invalid_modified_date_it_should_return_usage_error() {
|
||||||
|
let temp_file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(temp_file.path().to_path_buf()),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: Some("not-a-date".to_string()),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 2);
|
||||||
|
assert!(err.message.contains("Invalid modified_date"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_with_invalid_ids_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 0,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(PathBuf::from("test.txt")),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("record_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 0,
|
||||||
|
input: Some(PathBuf::from("test.txt")),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("field_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let temp_file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
upload_file_result: Err(OnspringError::Api {
|
||||||
|
status_code: 400,
|
||||||
|
message: "Invalid file type".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(temp_file.path().to_path_buf()),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::runtime(
|
||||||
|
"API request failed (400): Invalid file type"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_delete_succeeds_it_should_write_success_response() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
delete_file_result: Ok(()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
assert_eq!(written.trim(), r#"{"ok":true}"#);
|
||||||
|
assert_eq!(*mock_client.delete_file_record_id.lock().unwrap(), Some(1));
|
||||||
|
assert_eq!(*mock_client.delete_file_field_id.lock().unwrap(), Some(2));
|
||||||
|
assert_eq!(*mock_client.delete_file_file_id.lock().unwrap(), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_delete_succeeds_pretty_it_should_write_pretty_json() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
delete_file_result: Ok(()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, true).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
let expected = concat!("{\n", " \"ok\": true\n", "}");
|
||||||
|
assert_eq!(written.trim(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_delete_with_invalid_ids_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 0,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("record_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 0,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("field_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: -1,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("file_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_delete_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
delete_file_result: Err(OnspringError::Api {
|
||||||
|
status_code: 404,
|
||||||
|
message: "File not found".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::runtime("API request failed (404): File not found"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
use onspring::SaveListItemRequest;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::cli::ListsCommand;
|
||||||
|
use crate::client::OnspringRunner;
|
||||||
|
use crate::error::CliResult;
|
||||||
|
use crate::output::{SaveListItemResponseOutput, SuccessResponse, write_json};
|
||||||
|
use crate::validation::{parse_body, validate_positive_i32};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SaveListItemInput {
|
||||||
|
pub id: Option<Uuid>,
|
||||||
|
pub name: String,
|
||||||
|
pub numeric_value: Option<f64>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SaveListItemInput> for SaveListItemRequest {
|
||||||
|
fn from(value: SaveListItemInput) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
name: value.name,
|
||||||
|
numeric_value: value.numeric_value,
|
||||||
|
color: value.color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle<C: OnspringRunner, W: Write>(
|
||||||
|
command: &ListsCommand,
|
||||||
|
client: &C,
|
||||||
|
writer: &mut W,
|
||||||
|
pretty: bool,
|
||||||
|
) -> CliResult<()> {
|
||||||
|
handle_with_reader(command, client, writer, std::io::stdin(), pretty).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_with_reader<C: OnspringRunner, W: Write, R: Read>(
|
||||||
|
command: &ListsCommand,
|
||||||
|
client: &C,
|
||||||
|
writer: &mut W,
|
||||||
|
reader: R,
|
||||||
|
pretty: bool,
|
||||||
|
) -> CliResult<()> {
|
||||||
|
match command {
|
||||||
|
ListsCommand::SaveItem { list_id, body } => {
|
||||||
|
validate_positive_i32(*list_id, "list_id")?;
|
||||||
|
let input: SaveListItemInput = parse_body(body, reader)?;
|
||||||
|
let request: SaveListItemRequest = input.into();
|
||||||
|
let response = client.save_list_item(*list_id, request).await?;
|
||||||
|
let output: SaveListItemResponseOutput = response.into();
|
||||||
|
write_json(writer, &output, pretty)?;
|
||||||
|
}
|
||||||
|
ListsCommand::DeleteItem { list_id, item_id } => {
|
||||||
|
validate_positive_i32(*list_id, "list_id")?;
|
||||||
|
client.delete_list_item(*list_id, *item_id).await?;
|
||||||
|
write_json(writer, &SuccessResponse { ok: true }, pretty)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use onspring::{OnspringError, SaveListItemResponse};
|
||||||
|
|
||||||
|
use crate::cli::BodySource;
|
||||||
|
use crate::client::testing::MockClient;
|
||||||
|
use crate::error::CliError;
|
||||||
|
|
||||||
|
// --- SaveItem tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_save_item_succeeds_it_should_write_save_response() {
|
||||||
|
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
|
||||||
|
let mock_client = MockClient {
|
||||||
|
save_list_item_result: Ok(SaveListItemResponse { id: item_uuid }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ListsCommand::SaveItem {
|
||||||
|
list_id: 1,
|
||||||
|
body: BodySource {
|
||||||
|
json: Some(
|
||||||
|
r##"{"id":"d3b07384-d113-40e1-a20c-55c3c0a4e70e","name":"Item 1","numericValue":10.5,"color":"#ffffff"}"##
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
file: None,
|
||||||
|
stdin: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
written.trim(),
|
||||||
|
r#"{"id":"d3b07384-d113-40e1-a20c-55c3c0a4e70e"}"#
|
||||||
|
);
|
||||||
|
assert_eq!(*mock_client.save_list_item_list_id.lock().unwrap(), Some(1));
|
||||||
|
let req = mock_client
|
||||||
|
.save_list_item_request
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(req.id, Some(item_uuid));
|
||||||
|
assert_eq!(req.name, "Item 1");
|
||||||
|
assert_eq!(req.numeric_value, Some(10.5));
|
||||||
|
assert_eq!(req.color, Some("#ffffff".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_save_item_succeeds_pretty_it_should_write_pretty_json() {
|
||||||
|
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
|
||||||
|
let mock_client = MockClient {
|
||||||
|
save_list_item_result: Ok(SaveListItemResponse { id: item_uuid }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ListsCommand::SaveItem {
|
||||||
|
list_id: 1,
|
||||||
|
body: BodySource {
|
||||||
|
json: Some(r#"{"name":"New Item"}"#.to_string()),
|
||||||
|
file: None,
|
||||||
|
stdin: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, true).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
let expected = concat!(
|
||||||
|
"{\n",
|
||||||
|
" \"id\": \"d3b07384-d113-40e1-a20c-55c3c0a4e70e\"\n",
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
assert_eq!(written.trim(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_save_item_with_invalid_list_id_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = ListsCommand::SaveItem {
|
||||||
|
list_id: 0,
|
||||||
|
body: BodySource {
|
||||||
|
json: Some(r#"{"name":"Item"}"#.to_string()),
|
||||||
|
file: None,
|
||||||
|
stdin: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("list_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_save_item_has_missing_body_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = ListsCommand::SaveItem {
|
||||||
|
list_id: 1,
|
||||||
|
body: BodySource::default(),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage(
|
||||||
|
"A request body is required. Provide --json, --file, or --stdin."
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_save_item_reads_from_stdin() {
|
||||||
|
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
|
||||||
|
let mock_client = MockClient {
|
||||||
|
save_list_item_result: Ok(SaveListItemResponse { id: item_uuid }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ListsCommand::SaveItem {
|
||||||
|
list_id: 5,
|
||||||
|
body: BodySource {
|
||||||
|
json: None,
|
||||||
|
file: None,
|
||||||
|
stdin: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let stdin_data = r#"{"name":"From Stdin","numericValue":2.0}"#;
|
||||||
|
|
||||||
|
let result = handle_with_reader(
|
||||||
|
&command,
|
||||||
|
&mock_client,
|
||||||
|
&mut buffer,
|
||||||
|
stdin_data.as_bytes(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
written.trim(),
|
||||||
|
r#"{"id":"d3b07384-d113-40e1-a20c-55c3c0a4e70e"}"#
|
||||||
|
);
|
||||||
|
let req = mock_client
|
||||||
|
.save_list_item_request
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(req.name, "From Stdin");
|
||||||
|
assert_eq!(req.numeric_value, Some(2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_save_item_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
save_list_item_result: Err(OnspringError::Api {
|
||||||
|
status_code: 400,
|
||||||
|
message: "Invalid list item".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ListsCommand::SaveItem {
|
||||||
|
list_id: 1,
|
||||||
|
body: BodySource {
|
||||||
|
json: Some(r#"{"name":"Item"}"#.to_string()),
|
||||||
|
file: None,
|
||||||
|
stdin: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::runtime(
|
||||||
|
"API request failed (400): Invalid list item"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DeleteItem tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_delete_item_succeeds_it_should_write_success_response() {
|
||||||
|
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
|
||||||
|
let mock_client = MockClient {
|
||||||
|
delete_list_item_result: Ok(()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ListsCommand::DeleteItem {
|
||||||
|
list_id: 1,
|
||||||
|
item_id: item_uuid,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
assert_eq!(written.trim(), r#"{"ok":true}"#);
|
||||||
|
assert_eq!(*mock_client.delete_list_item_list_id.lock().unwrap(), Some(1));
|
||||||
|
assert_eq!(
|
||||||
|
*mock_client.delete_list_item_item_id.lock().unwrap(),
|
||||||
|
Some(item_uuid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_delete_item_succeeds_pretty_it_should_write_pretty_json() {
|
||||||
|
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
|
||||||
|
let mock_client = MockClient {
|
||||||
|
delete_list_item_result: Ok(()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ListsCommand::DeleteItem {
|
||||||
|
list_id: 1,
|
||||||
|
item_id: item_uuid,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, true).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
let expected = concat!("{\n", " \"ok\": true\n", "}");
|
||||||
|
assert_eq!(written.trim(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_delete_item_with_invalid_list_id_it_should_return_usage_error() {
|
||||||
|
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = ListsCommand::DeleteItem {
|
||||||
|
list_id: 0,
|
||||||
|
item_id: item_uuid,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("list_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_delete_item_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let item_uuid = Uuid::parse_str("d3b07384-d113-40e1-a20c-55c3c0a4e70e").unwrap();
|
||||||
|
let mock_client = MockClient {
|
||||||
|
delete_list_item_result: Err(OnspringError::Api {
|
||||||
|
status_code: 404,
|
||||||
|
message: "List item not found".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ListsCommand::DeleteItem {
|
||||||
|
list_id: 1,
|
||||||
|
item_id: item_uuid,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::runtime(
|
||||||
|
"API request failed (404): List item not found"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+7
-1
@@ -1,7 +1,10 @@
|
|||||||
pub mod apps;
|
pub mod apps;
|
||||||
pub mod fields;
|
pub mod fields;
|
||||||
|
pub mod files;
|
||||||
|
pub mod lists;
|
||||||
pub mod ping;
|
pub mod ping;
|
||||||
pub mod records;
|
pub mod records;
|
||||||
|
pub mod reports;
|
||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
@@ -18,6 +21,9 @@ pub async fn handle<C: OnspringRunner, W: Write>(
|
|||||||
Command::Ping => ping::handle(client, writer, cli.pretty).await,
|
Command::Ping => ping::handle(client, writer, cli.pretty).await,
|
||||||
Command::Apps { command } => apps::handle(command, client, writer, cli.pretty).await,
|
Command::Apps { command } => apps::handle(command, client, writer, cli.pretty).await,
|
||||||
Command::Fields { command } => fields::handle(command, client, writer, cli.pretty).await,
|
Command::Fields { command } => fields::handle(command, client, writer, cli.pretty).await,
|
||||||
Command::Records { command } => records::handle(command, client, writer, cli.pretty).await
|
Command::Records { command } => records::handle(command, client, writer, cli.pretty).await,
|
||||||
|
Command::Files { command } => files::handle(command, client, writer, cli.pretty).await,
|
||||||
|
Command::Lists { command } => lists::handle(command, client, writer, cli.pretty).await,
|
||||||
|
Command::Reports { command } => reports::handle(command, client, writer, cli.pretty).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use onspring::{DataFormat, ReportDataType};
|
||||||
|
|
||||||
|
use crate::cli::{ReportDataTypeArg, ReportsCommand};
|
||||||
|
use crate::client::OnspringRunner;
|
||||||
|
use crate::error::CliResult;
|
||||||
|
use crate::output::{PagedOutput, ReportDataOutput, ReportInfoOutput, write_json};
|
||||||
|
use crate::validation::{paging_request, validate_positive_i32};
|
||||||
|
|
||||||
|
impl From<ReportDataTypeArg> for ReportDataType {
|
||||||
|
fn from(value: ReportDataTypeArg) -> Self {
|
||||||
|
match value {
|
||||||
|
ReportDataTypeArg::ReportData => ReportDataType::ReportData,
|
||||||
|
ReportDataTypeArg::ChartData => ReportDataType::ChartData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle<C: OnspringRunner, W: Write>(
|
||||||
|
command: &ReportsCommand,
|
||||||
|
client: &C,
|
||||||
|
writer: &mut W,
|
||||||
|
pretty: bool,
|
||||||
|
) -> CliResult<()> {
|
||||||
|
match command {
|
||||||
|
ReportsCommand::Get {
|
||||||
|
report_id,
|
||||||
|
data_format,
|
||||||
|
data_type,
|
||||||
|
} => {
|
||||||
|
validate_positive_i32(*report_id, "report_id")?;
|
||||||
|
let data_format = data_format.map(DataFormat::from);
|
||||||
|
let data_type = data_type.map(ReportDataType::from);
|
||||||
|
let response = client.get_report(*report_id, data_format, data_type).await?;
|
||||||
|
let output: ReportDataOutput = response.into();
|
||||||
|
write_json(writer, &output, pretty)?;
|
||||||
|
}
|
||||||
|
ReportsCommand::List { app_id, paging } => {
|
||||||
|
validate_positive_i32(*app_id, "app_id")?;
|
||||||
|
let paging = paging_request(paging)?;
|
||||||
|
let response = client.list_reports(*app_id, paging).await?;
|
||||||
|
let output: PagedOutput<ReportInfoOutput> = response.into();
|
||||||
|
write_json(writer, &output, pretty)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use onspring::{OnspringError, PagedResponse, ReportData, ReportInfo, ReportRow};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::cli::{DataFormatArg, PagingArgs};
|
||||||
|
use crate::client::testing::MockClient;
|
||||||
|
use crate::error::CliError;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_get_succeeds_it_should_write_report_data_output() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_report_result: Ok(ReportData {
|
||||||
|
columns: Some(vec!["Col1".to_string(), "Col2".to_string()]),
|
||||||
|
rows: Some(vec![ReportRow {
|
||||||
|
record_id: Some(10),
|
||||||
|
cells: Some(vec![json!("Val1"), json!(42)]),
|
||||||
|
}]),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ReportsCommand::Get {
|
||||||
|
report_id: 123,
|
||||||
|
data_format: None,
|
||||||
|
data_type: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
written.trim(),
|
||||||
|
r#"{"columns":["Col1","Col2"],"rows":[{"recordId":10,"cells":["Val1",42]}]}"#
|
||||||
|
);
|
||||||
|
assert_eq!(*mock_client.get_report_report_id.lock().unwrap(), Some(123));
|
||||||
|
assert_eq!(
|
||||||
|
*mock_client.get_report_data_format.lock().unwrap(),
|
||||||
|
Some(None)
|
||||||
|
);
|
||||||
|
assert_eq!(*mock_client.get_report_data_type.lock().unwrap(), Some(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_get_with_format_and_type_it_should_pass_mapped_arguments() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_report_result: Ok(ReportData {
|
||||||
|
columns: Some(vec!["Header".to_string()]),
|
||||||
|
rows: Some(vec![]),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ReportsCommand::Get {
|
||||||
|
report_id: 456,
|
||||||
|
data_format: Some(DataFormatArg::Formatted),
|
||||||
|
data_type: Some(ReportDataTypeArg::ChartData),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
assert_eq!(*mock_client.get_report_report_id.lock().unwrap(), Some(456));
|
||||||
|
assert_eq!(
|
||||||
|
*mock_client.get_report_data_format.lock().unwrap(),
|
||||||
|
Some(Some(DataFormat::Formatted))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*mock_client.get_report_data_type.lock().unwrap(),
|
||||||
|
Some(Some(ReportDataType::ChartData))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_get_with_raw_and_report_data_it_should_pass_mapped_arguments() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_report_result: Ok(ReportData {
|
||||||
|
columns: None,
|
||||||
|
rows: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ReportsCommand::Get {
|
||||||
|
report_id: 789,
|
||||||
|
data_format: Some(DataFormatArg::Raw),
|
||||||
|
data_type: Some(ReportDataTypeArg::ReportData),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
assert_eq!(*mock_client.get_report_report_id.lock().unwrap(), Some(789));
|
||||||
|
assert_eq!(
|
||||||
|
*mock_client.get_report_data_format.lock().unwrap(),
|
||||||
|
Some(Some(DataFormat::Raw))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*mock_client.get_report_data_type.lock().unwrap(),
|
||||||
|
Some(Some(ReportDataType::ReportData))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_get_succeeds_pretty_it_should_write_pretty_json() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_report_result: Ok(ReportData {
|
||||||
|
columns: Some(vec!["Col".to_string()]),
|
||||||
|
rows: Some(vec![ReportRow {
|
||||||
|
record_id: Some(1),
|
||||||
|
cells: Some(vec![json!("Val")]),
|
||||||
|
}]),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ReportsCommand::Get {
|
||||||
|
report_id: 1,
|
||||||
|
data_format: None,
|
||||||
|
data_type: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, true).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
let expected = concat!(
|
||||||
|
"{\n",
|
||||||
|
" \"columns\": [\n",
|
||||||
|
" \"Col\"\n",
|
||||||
|
" ],\n",
|
||||||
|
" \"rows\": [\n",
|
||||||
|
" {\n",
|
||||||
|
" \"recordId\": 1,\n",
|
||||||
|
" \"cells\": [\n",
|
||||||
|
" \"Val\"\n",
|
||||||
|
" ]\n",
|
||||||
|
" }\n",
|
||||||
|
" ]\n",
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(written.trim(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_get_has_invalid_report_id_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = ReportsCommand::Get {
|
||||||
|
report_id: 0,
|
||||||
|
data_format: None,
|
||||||
|
data_type: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("report_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
assert!(mock_client.get_report_report_id.lock().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_get_has_negative_report_id_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = ReportsCommand::Get {
|
||||||
|
report_id: -5,
|
||||||
|
data_format: None,
|
||||||
|
data_type: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("report_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
assert!(mock_client.get_report_report_id.lock().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_get_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_report_result: Err(OnspringError::Api {
|
||||||
|
status_code: 404,
|
||||||
|
message: "Report not found".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ReportsCommand::Get {
|
||||||
|
report_id: 999,
|
||||||
|
data_format: None,
|
||||||
|
data_type: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::runtime("API request failed (404): Report not found"))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_list_succeeds_it_should_write_paged_output() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
list_reports_result: Ok(PagedResponse {
|
||||||
|
page_number: Some(1),
|
||||||
|
page_size: Some(50),
|
||||||
|
total_pages: Some(1),
|
||||||
|
total_records: Some(1),
|
||||||
|
items: Some(vec![ReportInfo {
|
||||||
|
app_id: 10,
|
||||||
|
id: 100,
|
||||||
|
name: Some("Open Tasks".to_string()),
|
||||||
|
description: Some("Report of open tasks".to_string()),
|
||||||
|
}]),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ReportsCommand::List {
|
||||||
|
app_id: 10,
|
||||||
|
paging: PagingArgs::default(),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
written.trim(),
|
||||||
|
r#"{"pageNumber":1,"pageSize":50,"totalPages":1,"totalRecords":1,"items":[{"appId":10,"id":100,"name":"Open Tasks","description":"Report of open tasks"}]}"#
|
||||||
|
);
|
||||||
|
assert_eq!(*mock_client.list_reports_app_id.lock().unwrap(), Some(10));
|
||||||
|
assert!(
|
||||||
|
mock_client
|
||||||
|
.list_reports_paging
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_list_with_paging_args_it_should_pass_paging_request() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
list_reports_result: Ok(PagedResponse {
|
||||||
|
page_number: Some(2),
|
||||||
|
page_size: Some(15),
|
||||||
|
total_pages: Some(3),
|
||||||
|
total_records: Some(40),
|
||||||
|
items: Some(vec![]),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ReportsCommand::List {
|
||||||
|
app_id: 10,
|
||||||
|
paging: PagingArgs {
|
||||||
|
page_number: Some(2),
|
||||||
|
page_size: Some(15),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let paging = mock_client
|
||||||
|
.list_reports_paging
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.take()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(paging.page_number, 2);
|
||||||
|
assert_eq!(paging.page_size, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_list_succeeds_pretty_it_should_write_pretty_json() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
list_reports_result: Ok(PagedResponse {
|
||||||
|
page_number: Some(1),
|
||||||
|
page_size: Some(50),
|
||||||
|
total_pages: Some(1),
|
||||||
|
total_records: Some(1),
|
||||||
|
items: Some(vec![ReportInfo {
|
||||||
|
app_id: 10,
|
||||||
|
id: 100,
|
||||||
|
name: Some("Open Tasks".to_string()),
|
||||||
|
description: None,
|
||||||
|
}]),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ReportsCommand::List {
|
||||||
|
app_id: 10,
|
||||||
|
paging: PagingArgs::default(),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, true).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let written = String::from_utf8(buffer).unwrap();
|
||||||
|
let expected = concat!(
|
||||||
|
"{\n",
|
||||||
|
" \"pageNumber\": 1,\n",
|
||||||
|
" \"pageSize\": 50,\n",
|
||||||
|
" \"totalPages\": 1,\n",
|
||||||
|
" \"totalRecords\": 1,\n",
|
||||||
|
" \"items\": [\n",
|
||||||
|
" {\n",
|
||||||
|
" \"appId\": 10,\n",
|
||||||
|
" \"id\": 100,\n",
|
||||||
|
" \"name\": \"Open Tasks\",\n",
|
||||||
|
" \"description\": null\n",
|
||||||
|
" }\n",
|
||||||
|
" ]\n",
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(written.trim(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_list_has_invalid_app_id_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = ReportsCommand::List {
|
||||||
|
app_id: 0,
|
||||||
|
paging: PagingArgs::default(),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("app_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
assert!(mock_client.list_reports_app_id.lock().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_list_has_invalid_paging_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = ReportsCommand::List {
|
||||||
|
app_id: 10,
|
||||||
|
paging: PagingArgs {
|
||||||
|
page_number: Some(-1),
|
||||||
|
page_size: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("page_number must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
assert!(mock_client.list_reports_paging.lock().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_reports_list_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
list_reports_result: Err(OnspringError::Api {
|
||||||
|
status_code: 403,
|
||||||
|
message: "Forbidden".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = ReportsCommand::List {
|
||||||
|
app_id: 10,
|
||||||
|
paging: PagingArgs::default(),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::runtime("API request failed (403): Forbidden"))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::cli::Cli;
|
||||||
|
use crate::error::{CliError, CliResult};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConfigFile {
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
pub pretty: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ResolvedConfig {
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
pub pretty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_config_path() -> Option<PathBuf> {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| std::env::var("USERPROFILE").ok())?;
|
||||||
|
|
||||||
|
Some(PathBuf::from(home).join(".config").join("onx").join("config.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_config_file(path: &Path, is_explicit: bool) -> CliResult<Option<ConfigFile>> {
|
||||||
|
if !path.exists() {
|
||||||
|
if is_explicit {
|
||||||
|
return Err(CliError::usage(format!(
|
||||||
|
"Config file not found: {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(path).map_err(|err| {
|
||||||
|
CliError::runtime(format!(
|
||||||
|
"Failed to read config file {}: {err}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let config: ConfigFile = serde_json::from_str(&contents).map_err(|err| {
|
||||||
|
CliError::runtime(format!(
|
||||||
|
"Failed to parse config file {}: {err}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Some(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_config(cli: &Cli, config_file: Option<&ConfigFile>) -> ResolvedConfig {
|
||||||
|
let config_file_default = ConfigFile::default();
|
||||||
|
let file = config_file.unwrap_or(&config_file_default);
|
||||||
|
|
||||||
|
// Precedence: CLI Flag > Config File > Environment Variable
|
||||||
|
let api_key = cli
|
||||||
|
.api_key
|
||||||
|
.clone()
|
||||||
|
.or_else(|| file.api_key.clone())
|
||||||
|
.or_else(|| std::env::var("ONSPRING_API_KEY").ok());
|
||||||
|
|
||||||
|
let base_url = cli
|
||||||
|
.base_url
|
||||||
|
.clone()
|
||||||
|
.or_else(|| file.base_url.clone())
|
||||||
|
.or_else(|| std::env::var("ONSPRING_BASE_URL").ok());
|
||||||
|
|
||||||
|
let pretty = if cli.pretty {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
file.pretty.unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
ResolvedConfig {
|
||||||
|
api_key,
|
||||||
|
base_url,
|
||||||
|
pretty,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::cli::Command;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_file_when_file_exists_it_should_parse_camel_case_json() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(
|
||||||
|
file,
|
||||||
|
r#"{{"apiKey":"file-key","baseUrl":"https://config.onspring.com","pretty":true}}"#
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let loaded = load_config_file(file.path(), true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
loaded,
|
||||||
|
Some(ConfigFile {
|
||||||
|
api_key: Some("file-key".to_string()),
|
||||||
|
base_url: Some("https://config.onspring.com".to_string()),
|
||||||
|
pretty: Some(true),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_file_when_not_explicit_and_file_missing_it_should_return_none() {
|
||||||
|
let missing_path = PathBuf::from("non_existent_config_file_path_12345.json");
|
||||||
|
let loaded = load_config_file(&missing_path, false).unwrap();
|
||||||
|
assert_eq!(loaded, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_file_when_explicit_and_file_missing_it_should_return_usage_error() {
|
||||||
|
let missing_path = PathBuf::from("non_existent_config_file_path_12345.json");
|
||||||
|
let err = load_config_file(&missing_path, true).unwrap_err();
|
||||||
|
assert_eq!(err.code, 2);
|
||||||
|
assert!(err.message.contains("Config file not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_file_when_invalid_json_it_should_return_runtime_error() {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(file, "not json").unwrap();
|
||||||
|
|
||||||
|
let err = load_config_file(file.path(), true).unwrap_err();
|
||||||
|
assert_eq!(err.code, 1);
|
||||||
|
assert!(err.message.contains("Failed to parse config file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_path_when_home_or_userprofile_set_it_should_resolve_path() {
|
||||||
|
let path = default_config_path();
|
||||||
|
assert!(path.is_some());
|
||||||
|
let p = path.unwrap();
|
||||||
|
assert!(p.ends_with(".config/onx/config.json") || p.ends_with(r#".config\onx\config.json"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_config_when_cli_flag_provided_it_should_take_precedence() {
|
||||||
|
let cli = Cli {
|
||||||
|
api_key: Some("cli-key".to_string()),
|
||||||
|
base_url: Some("https://cli.onspring.com".to_string()),
|
||||||
|
config: None,
|
||||||
|
pretty: true,
|
||||||
|
command: Command::Ping,
|
||||||
|
};
|
||||||
|
let file = ConfigFile {
|
||||||
|
api_key: Some("file-key".to_string()),
|
||||||
|
base_url: Some("https://file.onspring.com".to_string()),
|
||||||
|
pretty: Some(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved = resolve_config(&cli, Some(&file));
|
||||||
|
assert_eq!(
|
||||||
|
resolved,
|
||||||
|
ResolvedConfig {
|
||||||
|
api_key: Some("cli-key".to_string()),
|
||||||
|
base_url: Some("https://cli.onspring.com".to_string()),
|
||||||
|
pretty: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_config_when_cli_flag_absent_it_should_use_config_file() {
|
||||||
|
let cli = Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Ping,
|
||||||
|
};
|
||||||
|
let file = ConfigFile {
|
||||||
|
api_key: Some("file-key".to_string()),
|
||||||
|
base_url: Some("https://file.onspring.com".to_string()),
|
||||||
|
pretty: Some(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved = resolve_config(&cli, Some(&file));
|
||||||
|
assert_eq!(
|
||||||
|
resolved,
|
||||||
|
ResolvedConfig {
|
||||||
|
api_key: Some("file-key".to_string()),
|
||||||
|
base_url: Some("https://file.onspring.com".to_string()),
|
||||||
|
pretty: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_config_when_no_file_it_should_default_pretty_to_false() {
|
||||||
|
let cli = Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Ping,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved = resolve_config(&cli, None);
|
||||||
|
assert!(!resolved.pretty);
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
-5
@@ -1,6 +1,7 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
mod client;
|
mod client;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod config;
|
||||||
mod error;
|
mod error;
|
||||||
mod output;
|
mod output;
|
||||||
mod validation;
|
mod validation;
|
||||||
@@ -9,17 +10,28 @@ use onspring::OnspringClient;
|
|||||||
|
|
||||||
pub use cli::{Cli, Command, parse_cli_from};
|
pub use cli::{Cli, Command, parse_cli_from};
|
||||||
pub use client::OnspringRunner;
|
pub use client::OnspringRunner;
|
||||||
|
pub use config::{ConfigFile, ResolvedConfig, default_config_path, load_config_file, resolve_config};
|
||||||
pub use error::{CliError, CliResult, render_cli_error};
|
pub use error::{CliError, CliResult, render_cli_error};
|
||||||
|
|
||||||
pub async fn run(cli: Cli) -> CliResult<()> {
|
pub async fn run(mut cli: Cli) -> CliResult<()> {
|
||||||
let api_key = cli
|
let config_file = if let Some(config_path) = &cli.config {
|
||||||
|
load_config_file(config_path, true)?
|
||||||
|
} else if let Some(default_path) = default_config_path() {
|
||||||
|
load_config_file(&default_path, false)?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved = resolve_config(&cli, config_file.as_ref());
|
||||||
|
|
||||||
|
let api_key = resolved
|
||||||
.api_key
|
.api_key
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| CliError::usage("Missing API Key. Set --api-key or ONSPRING_API_KEY."))?;
|
.ok_or_else(|| CliError::usage("Missing API Key. Set --api-key, config file, or ONSPRING_API_KEY."))?;
|
||||||
|
|
||||||
let mut builder = OnspringClient::builder(api_key.to_string());
|
let mut builder = OnspringClient::builder(api_key.to_string());
|
||||||
|
|
||||||
if let Some(base_url) = &cli.base_url {
|
if let Some(base_url) = &resolved.base_url {
|
||||||
if base_url.trim().is_empty() {
|
if base_url.trim().is_empty() {
|
||||||
return Err(CliError::usage("Base URL cannot be empty."));
|
return Err(CliError::usage("Base URL cannot be empty."));
|
||||||
}
|
}
|
||||||
@@ -27,6 +39,8 @@ pub async fn run(cli: Cli) -> CliResult<()> {
|
|||||||
builder = builder.base_url(base_url);
|
builder = builder.base_url(base_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cli.pretty = resolved.pretty;
|
||||||
|
|
||||||
let client = builder.build();
|
let client = builder.build();
|
||||||
let mut stdout = std::io::stdout();
|
let mut stdout = std::io::stdout();
|
||||||
run_with_client(&cli, &client, &mut stdout).await
|
run_with_client(&cli, &client, &mut stdout).await
|
||||||
@@ -43,12 +57,15 @@ pub async fn run_with_client<C: OnspringRunner, W: std::io::Write>(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_when_missing_api_key_it_should_return_usage_error() {
|
async fn run_when_missing_api_key_it_should_return_usage_error() {
|
||||||
let cli = Cli {
|
let cli = Cli {
|
||||||
api_key: None,
|
api_key: None,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Ping,
|
command: Command::Ping,
|
||||||
};
|
};
|
||||||
@@ -58,16 +75,59 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Err(CliError::usage(
|
Err(CliError::usage(
|
||||||
"Missing API Key. Set --api-key or ONSPRING_API_KEY."
|
"Missing API Key. Set --api-key, config file, or ONSPRING_API_KEY."
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_when_api_key_in_config_file_it_should_succeed() {
|
||||||
|
let mut config_file = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(config_file, r#"{{"apiKey":"config-key"}}"#).unwrap();
|
||||||
|
|
||||||
|
let cli = Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: Some(config_file.path().to_path_buf()),
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Ping,
|
||||||
|
};
|
||||||
|
|
||||||
|
// run builds the client with config-key and calls ping
|
||||||
|
// Since OnspringClient builder does not hit network until execute,
|
||||||
|
// calling run will fail at runtime network request or succeed if network is mocked,
|
||||||
|
// but here run creates an OnspringClient and executes ping against real endpoint (network error).
|
||||||
|
let result = run(cli).await;
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_when_empty_base_url_in_config_file_it_should_return_usage_error() {
|
||||||
|
let mut config_file = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(
|
||||||
|
config_file,
|
||||||
|
r#"{{"apiKey":"config-key","baseUrl":" "}}"#
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let cli = Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: Some(config_file.path().to_path_buf()),
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Ping,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = run(cli).await;
|
||||||
|
assert_eq!(result, Err(CliError::usage("Base URL cannot be empty.")));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_when_empty_base_url_it_should_return_usage_error() {
|
async fn run_when_empty_base_url_it_should_return_usage_error() {
|
||||||
let cli = Cli {
|
let cli = Cli {
|
||||||
api_key: Some("test-api-key".to_string()),
|
api_key: Some("test-api-key".to_string()),
|
||||||
base_url: Some(" ".to_string()),
|
base_url: Some(" ".to_string()),
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Ping,
|
command: Command::Ping,
|
||||||
};
|
};
|
||||||
@@ -77,12 +137,33 @@ mod tests {
|
|||||||
assert_eq!(result, Err(CliError::usage("Base URL cannot be empty.")));
|
assert_eq!(result, Err(CliError::usage("Base URL cannot be empty.")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_when_config_file_has_invalid_json_it_should_return_runtime_error() {
|
||||||
|
let mut config_file = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(config_file, "invalid-json").unwrap();
|
||||||
|
|
||||||
|
let cli = Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
config: Some(config_file.path().to_path_buf()),
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Ping,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = run(cli).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 1);
|
||||||
|
assert!(err.message.contains("Failed to parse config file"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_with_client_it_should_delegate_to_commands() {
|
async fn run_with_client_it_should_delegate_to_commands() {
|
||||||
let mock_client = client::testing::MockClient::default();
|
let mock_client = client::testing::MockClient::default();
|
||||||
let cli = Cli {
|
let cli = Cli {
|
||||||
api_key: Some("key".to_string()),
|
api_key: Some("key".to_string()),
|
||||||
base_url: None,
|
base_url: None,
|
||||||
|
config: None,
|
||||||
pretty: false,
|
pretty: false,
|
||||||
command: Command::Ping,
|
command: Command::Ping,
|
||||||
};
|
};
|
||||||
|
|||||||
+111
-2
@@ -1,8 +1,10 @@
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use onspring::{
|
use onspring::{
|
||||||
App, CollectionResponse, Field, FormulaOutputType, ListFieldValue, Multiplicity, PagedResponse,
|
App, CollectionResponse, CreatedWithIdResponse, Field, FileInfo, FormulaOutputType,
|
||||||
Record, RecordFieldValue, SaveRecordResponse, ValueType,
|
ListFieldValue, Multiplicity, PagedResponse, Record, RecordFieldValue, ReportData, ReportInfo,
|
||||||
|
ReportRow, SaveListItemResponse, SaveRecordResponse, ValueType,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -208,6 +210,113 @@ impl From<SaveRecordResponse> for SaveRecordResponseOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileInfoOutput {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub file_type: Option<String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub created_date: Option<DateTime<Utc>>,
|
||||||
|
pub modified_date: Option<DateTime<Utc>>,
|
||||||
|
pub owner: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub file_href: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<FileInfo> for FileInfoOutput {
|
||||||
|
fn from(value: FileInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
file_type: value.file_type,
|
||||||
|
content_type: value.content_type,
|
||||||
|
name: value.name,
|
||||||
|
created_date: value.created_date,
|
||||||
|
modified_date: value.modified_date,
|
||||||
|
owner: value.owner,
|
||||||
|
notes: value.notes,
|
||||||
|
file_href: value.file_href,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreatedWithIdResponseOutput {
|
||||||
|
pub id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CreatedWithIdResponse> for CreatedWithIdResponseOutput {
|
||||||
|
fn from(value: CreatedWithIdResponse) -> Self {
|
||||||
|
Self { id: value.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SaveListItemResponseOutput {
|
||||||
|
pub id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SaveListItemResponse> for SaveListItemResponseOutput {
|
||||||
|
fn from(value: SaveListItemResponse) -> Self {
|
||||||
|
Self { id: value.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReportInfoOutput {
|
||||||
|
pub app_id: i32,
|
||||||
|
pub id: i32,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ReportInfo> for ReportInfoOutput {
|
||||||
|
fn from(value: ReportInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
app_id: value.app_id,
|
||||||
|
id: value.id,
|
||||||
|
name: value.name,
|
||||||
|
description: value.description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReportRowOutput {
|
||||||
|
pub record_id: Option<i32>,
|
||||||
|
pub cells: Option<Vec<serde_json::Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ReportRow> for ReportRowOutput {
|
||||||
|
fn from(value: ReportRow) -> Self {
|
||||||
|
Self {
|
||||||
|
record_id: value.record_id,
|
||||||
|
cells: value.cells,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReportDataOutput {
|
||||||
|
pub columns: Option<Vec<String>>,
|
||||||
|
pub rows: Option<Vec<ReportRowOutput>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ReportData> for ReportDataOutput {
|
||||||
|
fn from(value: ReportData) -> Self {
|
||||||
|
Self {
|
||||||
|
columns: value.columns,
|
||||||
|
rows: value
|
||||||
|
.rows
|
||||||
|
.map(|rows| rows.into_iter().map(ReportRowOutput::from).collect()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_json<T: Serialize>(value: &T, pretty: bool) -> CliResult<String> {
|
pub fn render_json<T: Serialize>(value: &T, pretty: bool) -> CliResult<String> {
|
||||||
if pretty {
|
if pretty {
|
||||||
serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output"))
|
serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output"))
|
||||||
|
|||||||
+5
-19
@@ -58,11 +58,7 @@ pub fn validate_ids(ids: &[i32], name: &str, max_len: Option<usize>) -> CliResul
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<String> {
|
pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<String> {
|
||||||
let count = [
|
let count = [source.json.is_some(), source.file.is_some(), source.stdin]
|
||||||
source.json.is_some(),
|
|
||||||
source.file.is_some(),
|
|
||||||
source.stdin,
|
|
||||||
]
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|&b| b)
|
.filter(|&b| b)
|
||||||
.count();
|
.count();
|
||||||
@@ -84,13 +80,8 @@ pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref path) = source.file {
|
if let Some(ref path) = source.file {
|
||||||
return std::fs::read_to_string(path).map_err(|e| {
|
return std::fs::read_to_string(path)
|
||||||
CliError::usage(format!(
|
.map_err(|e| CliError::usage(format!("Failed to read file '{}': {}", path.display(), e)));
|
||||||
"Failed to read file '{}': {}",
|
|
||||||
path.display(),
|
|
||||||
e
|
|
||||||
))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if source.stdin {
|
if source.stdin {
|
||||||
@@ -104,10 +95,7 @@ pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<Strin
|
|||||||
unreachable!()
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_body<T: DeserializeOwned, R: Read>(
|
pub fn parse_body<T: DeserializeOwned, R: Read>(source: &BodySource, reader: R) -> CliResult<T> {
|
||||||
source: &BodySource,
|
|
||||||
reader: R,
|
|
||||||
) -> CliResult<T> {
|
|
||||||
let content = read_body(source, reader)?;
|
let content = read_body(source, reader)?;
|
||||||
serde_json::from_str(&content)
|
serde_json::from_str(&content)
|
||||||
.map_err(|e| CliError::usage(format!("Invalid JSON request body: {e}")))
|
.map_err(|e| CliError::usage(format!("Invalid JSON request body: {e}")))
|
||||||
@@ -261,9 +249,7 @@ mod tests {
|
|||||||
let ids = vec![1; 101];
|
let ids = vec![1; 101];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
validate_ids(&ids, "ids", Some(100)),
|
validate_ids(&ids, "ids", Some(100)),
|
||||||
Err(CliError::usage(
|
Err(CliError::usage("ids cannot contain more than 100 values."))
|
||||||
"ids cannot contain more than 100 values."
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user