re-uploading work
This commit is contained in:
65
crates/cli/Cargo.toml
Normal file
65
crates/cli/Cargo.toml
Normal file
@@ -0,0 +1,65 @@
|
||||
[package]
|
||||
name = "attune-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "attune"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Internal dependencies
|
||||
attune-common = { path = "../common" }
|
||||
attune-worker = { path = "../worker" }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
|
||||
# CLI framework
|
||||
clap = { workspace = true, features = ["derive", "env", "string"] }
|
||||
|
||||
# HTTP client
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml_ng = { workspace = true }
|
||||
|
||||
# Error handling
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Date/Time
|
||||
chrono = { workspace = true }
|
||||
|
||||
# Configuration
|
||||
config = { workspace = true }
|
||||
dirs = "5.0"
|
||||
|
||||
# URL encoding
|
||||
urlencoding = "2.1"
|
||||
|
||||
# Terminal UI
|
||||
colored = "2.1"
|
||||
comfy-table = "7.1"
|
||||
indicatif = "0.17"
|
||||
dialoguer = "0.11"
|
||||
|
||||
# Authentication
|
||||
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
wiremock = "0.6"
|
||||
assert_cmd = "2.0"
|
||||
predicates = "3.0"
|
||||
mockito = "1.2"
|
||||
tokio-test = "0.4"
|
||||
591
crates/cli/README.md
Normal file
591
crates/cli/README.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# Attune CLI
|
||||
|
||||
The Attune CLI is a command-line interface for interacting with the Attune automation platform. It provides an intuitive and flexible interface for managing packs, actions, rules, sensors, triggers, and executions.
|
||||
|
||||
## Installation
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
cargo install --path crates/cli
|
||||
```
|
||||
|
||||
The binary will be named `attune`.
|
||||
|
||||
### Development Build
|
||||
|
||||
```bash
|
||||
cargo build -p attune-cli
|
||||
./target/debug/attune --help
|
||||
```
|
||||
|
||||
### Release Build
|
||||
|
||||
```bash
|
||||
cargo build -p attune-cli --release
|
||||
./target/release/attune --help
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI stores configuration in `~/.config/attune/config.yaml` (or `$XDG_CONFIG_HOME/attune/config.yaml`).
|
||||
|
||||
Default configuration:
|
||||
```yaml
|
||||
api_url: http://localhost:8080
|
||||
auth_token: null
|
||||
refresh_token: null
|
||||
output_format: table
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `ATTUNE_API_URL`: Override the API endpoint URL
|
||||
- Standard XDG environment variables for config directory location
|
||||
|
||||
### Global Flags
|
||||
|
||||
All commands support these global flags:
|
||||
|
||||
- `--api-url <URL>`: Override the API endpoint (also via `ATTUNE_API_URL`)
|
||||
- `--output <FORMAT>`: Output format (`table`, `json`, `yaml`)
|
||||
- `-j, --json`: Output as JSON (shorthand for `--output json`)
|
||||
- `-y, --yaml`: Output as YAML (shorthand for `--output yaml`)
|
||||
- `-v, --verbose`: Enable verbose logging
|
||||
|
||||
## Authentication
|
||||
|
||||
### Login
|
||||
|
||||
```bash
|
||||
# Interactive password prompt
|
||||
attune auth login --username admin
|
||||
|
||||
# With password (not recommended for interactive use)
|
||||
attune auth login --username admin --password secret
|
||||
|
||||
# With custom API URL
|
||||
attune auth login --username admin --api-url https://attune.example.com
|
||||
```
|
||||
|
||||
### Logout
|
||||
|
||||
```bash
|
||||
attune auth logout
|
||||
```
|
||||
|
||||
### Check Current User
|
||||
|
||||
```bash
|
||||
attune auth whoami
|
||||
```
|
||||
|
||||
## Pack Management
|
||||
|
||||
### List Packs
|
||||
|
||||
```bash
|
||||
# List all packs
|
||||
attune pack list
|
||||
|
||||
# Filter by name
|
||||
attune pack list --name core
|
||||
|
||||
# JSON output (long form)
|
||||
attune pack list --output json
|
||||
|
||||
# JSON output (shorthand)
|
||||
attune pack list -j
|
||||
|
||||
# YAML output (shorthand)
|
||||
attune pack list -y
|
||||
```
|
||||
|
||||
### Show Pack Details
|
||||
|
||||
```bash
|
||||
# By name
|
||||
attune pack show core
|
||||
|
||||
# By ID
|
||||
attune pack show 1
|
||||
```
|
||||
|
||||
### Install Pack
|
||||
|
||||
```bash
|
||||
# From git repository
|
||||
attune pack install https://github.com/example/attune-pack-example
|
||||
|
||||
# From git with specific branch/tag
|
||||
attune pack install https://github.com/example/attune-pack-example --ref v1.0.0
|
||||
|
||||
# Force reinstall
|
||||
attune pack install https://github.com/example/attune-pack-example --force
|
||||
```
|
||||
|
||||
### Register Local Pack
|
||||
|
||||
```bash
|
||||
# Register from local directory
|
||||
attune pack register /path/to/pack
|
||||
```
|
||||
|
||||
### Uninstall Pack
|
||||
|
||||
```bash
|
||||
# Interactive confirmation
|
||||
attune pack uninstall core
|
||||
|
||||
# Skip confirmation
|
||||
attune pack uninstall core --yes
|
||||
```
|
||||
|
||||
## Action Management
|
||||
|
||||
### List Actions
|
||||
|
||||
```bash
|
||||
# List all actions
|
||||
attune action list
|
||||
|
||||
# Filter by pack
|
||||
attune action list --pack core
|
||||
|
||||
# Filter by name
|
||||
attune action list --name execute
|
||||
```
|
||||
|
||||
### Show Action Details
|
||||
|
||||
```bash
|
||||
# By pack.action reference
|
||||
attune action show core.echo
|
||||
|
||||
# By ID
|
||||
attune action show 1
|
||||
```
|
||||
|
||||
### Execute Action
|
||||
|
||||
```bash
|
||||
# With key=value parameters
|
||||
attune action execute core.echo --param message="Hello World" --param count=3
|
||||
|
||||
# With JSON parameters
|
||||
attune action execute core.echo --params-json '{"message": "Hello", "count": 5}'
|
||||
|
||||
# Wait for completion
|
||||
attune action execute core.long_task --wait
|
||||
|
||||
# Wait with custom timeout (default 300 seconds)
|
||||
attune action execute core.long_task --wait --timeout 600
|
||||
```
|
||||
|
||||
## Rule Management
|
||||
|
||||
### List Rules
|
||||
|
||||
```bash
|
||||
# List all rules
|
||||
attune rule list
|
||||
|
||||
# Filter by pack
|
||||
attune rule list --pack core
|
||||
|
||||
# Filter by enabled status
|
||||
attune rule list --enabled true
|
||||
```
|
||||
|
||||
### Show Rule Details
|
||||
|
||||
```bash
|
||||
# By pack.rule reference
|
||||
attune rule show core.on_webhook
|
||||
|
||||
# By ID
|
||||
attune rule show 1
|
||||
```
|
||||
|
||||
### Enable/Disable Rules
|
||||
|
||||
```bash
|
||||
# Enable a rule
|
||||
attune rule enable core.on_webhook
|
||||
|
||||
# Disable a rule
|
||||
attune rule disable core.on_webhook
|
||||
```
|
||||
|
||||
### Create Rule
|
||||
|
||||
```bash
|
||||
attune rule create \
|
||||
--name my_rule \
|
||||
--pack core \
|
||||
--trigger core.webhook \
|
||||
--action core.notify \
|
||||
--description "Notify on webhook" \
|
||||
--enabled
|
||||
|
||||
# With criteria
|
||||
attune rule create \
|
||||
--name filtered_rule \
|
||||
--pack core \
|
||||
--trigger core.webhook \
|
||||
--action core.notify \
|
||||
--criteria '{"trigger.payload.severity": "critical"}'
|
||||
```
|
||||
|
||||
### Delete Rule
|
||||
|
||||
```bash
|
||||
# Interactive confirmation
|
||||
attune rule delete core.my_rule
|
||||
|
||||
# Skip confirmation
|
||||
attune rule delete core.my_rule --yes
|
||||
```
|
||||
|
||||
## Execution Monitoring
|
||||
|
||||
### List Executions
|
||||
|
||||
```bash
|
||||
# List recent executions (default: last 50)
|
||||
attune execution list
|
||||
|
||||
# Filter by pack
|
||||
attune execution list --pack core
|
||||
|
||||
# Filter by action
|
||||
attune execution list --action core.echo
|
||||
|
||||
# Filter by status
|
||||
attune execution list --status succeeded
|
||||
|
||||
# Search in execution results
|
||||
attune execution list --result "error"
|
||||
|
||||
# Combine filters
|
||||
attune execution list --pack monitoring --status failed --result "timeout"
|
||||
|
||||
# Limit results
|
||||
attune execution list --limit 100
|
||||
```
|
||||
|
||||
### Show Execution Details
|
||||
|
||||
```bash
|
||||
attune execution show 123
|
||||
```
|
||||
|
||||
### View Execution Logs
|
||||
|
||||
```bash
|
||||
# Show logs
|
||||
attune execution logs 123
|
||||
|
||||
# Follow logs (real-time)
|
||||
attune execution logs 123 --follow
|
||||
```
|
||||
|
||||
### Cancel Execution
|
||||
|
||||
```bash
|
||||
# Interactive confirmation
|
||||
attune execution cancel 123
|
||||
|
||||
# Skip confirmation
|
||||
attune execution cancel 123 --yes
|
||||
```
|
||||
|
||||
### Get Raw Execution Result
|
||||
|
||||
Get just the result data from a completed execution, useful for piping to other tools.
|
||||
|
||||
```bash
|
||||
# Get result as JSON (default)
|
||||
attune execution result 123
|
||||
|
||||
# Get result as YAML
|
||||
attune execution result 123 --format yaml
|
||||
|
||||
# Pipe to jq for processing
|
||||
attune execution result 123 | jq '.data.field'
|
||||
|
||||
# Extract specific field
|
||||
attune execution result 123 | jq -r '.status'
|
||||
```
|
||||
|
||||
## Trigger Management
|
||||
|
||||
### List Triggers
|
||||
|
||||
```bash
|
||||
# List all triggers
|
||||
attune trigger list
|
||||
|
||||
# Filter by pack
|
||||
attune trigger list --pack core
|
||||
```
|
||||
|
||||
### Show Trigger Details
|
||||
|
||||
```bash
|
||||
attune trigger show core.webhook
|
||||
```
|
||||
|
||||
## Sensor Management
|
||||
|
||||
### List Sensors
|
||||
|
||||
```bash
|
||||
# List all sensors
|
||||
attune sensor list
|
||||
|
||||
# Filter by pack
|
||||
attune sensor list --pack core
|
||||
```
|
||||
|
||||
### Show Sensor Details
|
||||
|
||||
```bash
|
||||
attune sensor show core.file_watcher
|
||||
```
|
||||
|
||||
## CLI Configuration
|
||||
|
||||
### List Configuration
|
||||
|
||||
```bash
|
||||
attune config list
|
||||
```
|
||||
|
||||
### Get Configuration Value
|
||||
|
||||
```bash
|
||||
attune config get api_url
|
||||
```
|
||||
|
||||
### Set Configuration Value
|
||||
|
||||
```bash
|
||||
# Set API URL
|
||||
attune config set api_url https://attune.example.com
|
||||
|
||||
# Set output format
|
||||
attune config set output_format json
|
||||
```
|
||||
|
||||
### Show Configuration File Path
|
||||
|
||||
```bash
|
||||
attune config path
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
### Table (Default)
|
||||
|
||||
Human-readable table format with colored output:
|
||||
|
||||
```bash
|
||||
attune pack list
|
||||
```
|
||||
|
||||
### JSON
|
||||
|
||||
Machine-readable JSON for scripting:
|
||||
|
||||
```bash
|
||||
# Long form
|
||||
attune pack list --output json
|
||||
|
||||
# Shorthand
|
||||
attune pack list -j
|
||||
```
|
||||
|
||||
### YAML
|
||||
|
||||
YAML format:
|
||||
|
||||
```bash
|
||||
# Long form
|
||||
attune pack list --output yaml
|
||||
|
||||
# Shorthand
|
||||
attune pack list -y
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Workflow Example
|
||||
|
||||
```bash
|
||||
# 1. Login
|
||||
attune auth login --username admin
|
||||
|
||||
# 2. Install a pack
|
||||
attune pack install https://github.com/example/monitoring-pack
|
||||
|
||||
# 3. List available actions
|
||||
attune action list --pack monitoring
|
||||
|
||||
# 4. Execute an action
|
||||
attune action execute monitoring.check_health --param endpoint=https://api.example.com
|
||||
|
||||
# 5. Enable a rule
|
||||
attune rule enable monitoring.alert_on_failure
|
||||
|
||||
# 6. Monitor executions
|
||||
attune execution list --action monitoring.check_health
|
||||
```
|
||||
|
||||
### Scripting Example
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Deploy and test a pack
|
||||
|
||||
set -e
|
||||
|
||||
PACK_URL="https://github.com/example/my-pack"
|
||||
PACK_NAME="my-pack"
|
||||
|
||||
# Install pack
|
||||
echo "Installing pack..."
|
||||
attune pack install "$PACK_URL" -j | jq -r '.id'
|
||||
|
||||
# Verify installation
|
||||
echo "Verifying pack..."
|
||||
PACK_ID=$(attune pack list --name "$PACK_NAME" -j | jq -r '.[0].id')
|
||||
|
||||
if [ -z "$PACK_ID" ]; then
|
||||
echo "Pack installation failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pack installed successfully with ID: $PACK_ID"
|
||||
|
||||
# List actions in the pack
|
||||
echo "Actions in pack:"
|
||||
attune action list --pack "$PACK_NAME"
|
||||
|
||||
# Enable all rules in the pack
|
||||
attune rule list --pack "$PACK_NAME" -j | \
|
||||
jq -r '.[].id' | \
|
||||
xargs -I {} attune rule enable {}
|
||||
|
||||
echo "All rules enabled"
|
||||
```
|
||||
|
||||
### Process Execution Results
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Extract and process execution results
|
||||
|
||||
EXECUTION_ID=123
|
||||
|
||||
# Get raw result
|
||||
RESULT=$(attune execution result $EXECUTION_ID)
|
||||
|
||||
# Extract specific fields
|
||||
STATUS=$(echo "$RESULT" | jq -r '.status')
|
||||
MESSAGE=$(echo "$RESULT" | jq -r '.message')
|
||||
|
||||
echo "Status: $STATUS"
|
||||
echo "Message: $MESSAGE"
|
||||
|
||||
# Or pipe directly
|
||||
attune execution result $EXECUTION_ID | jq -r '.errors[]'
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Issues
|
||||
|
||||
If you get authentication errors:
|
||||
|
||||
1. Check you're logged in: `attune auth whoami`
|
||||
2. Try logging in again: `attune auth login --username <user>`
|
||||
3. Verify API URL: `attune config get api_url`
|
||||
|
||||
### Connection Issues
|
||||
|
||||
If you can't connect to the API:
|
||||
|
||||
1. Verify the API is running: `curl http://localhost:8080/health`
|
||||
2. Check the configured URL: `attune config get api_url`
|
||||
3. Override the URL: `attune --api-url http://localhost:8080 auth whoami`
|
||||
|
||||
### Verbose Logging
|
||||
|
||||
Enable verbose logging for debugging:
|
||||
|
||||
```bash
|
||||
attune --verbose pack list
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
cargo build -p attune-cli
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
cargo test -p attune-cli
|
||||
```
|
||||
|
||||
### Code Structure
|
||||
|
||||
```
|
||||
crates/cli/
|
||||
├── src/
|
||||
│ ├── main.rs # Entry point and CLI structure
|
||||
│ ├── client.rs # HTTP client for API calls
|
||||
│ ├── config.rs # Configuration management
|
||||
│ ├── output.rs # Output formatting (table, JSON, YAML)
|
||||
│ └── commands/ # Command implementations
|
||||
│ ├── auth.rs # Authentication commands
|
||||
│ ├── pack.rs # Pack management commands
|
||||
│ ├── action.rs # Action commands
|
||||
│ ├── rule.rs # Rule commands
|
||||
│ ├── execution.rs # Execution commands
|
||||
│ ├── trigger.rs # Trigger commands
|
||||
│ ├── sensor.rs # Sensor commands
|
||||
│ └── config.rs # Config commands
|
||||
└── Cargo.toml
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ JWT authentication with token storage
|
||||
- ✅ Multiple output formats (table, JSON, YAML)
|
||||
- ✅ Colored and formatted table output
|
||||
- ✅ Interactive prompts for sensitive operations
|
||||
- ✅ Configuration management
|
||||
- ✅ Advanced execution search (by pack, action, status, result content)
|
||||
- ✅ Comprehensive pack management
|
||||
- ✅ Action execution with parameter support
|
||||
- ✅ Rule creation and management
|
||||
- ✅ Execution monitoring and logs with advanced filtering
|
||||
- ✅ Raw result extraction for piping to other tools
|
||||
- ✅ Shorthand output flags (`-j`, `-y`) for CLI convenience
|
||||
- ✅ Environment variable overrides
|
||||
|
||||
## Dependencies
|
||||
|
||||
Key dependencies:
|
||||
- `clap`: CLI argument parsing
|
||||
- `reqwest`: HTTP client
|
||||
- `serde_json` / `serde_yaml`: Serialization
|
||||
- `colored`: Terminal colors
|
||||
- `comfy-table`: Table formatting
|
||||
- `dialoguer`: Interactive prompts
|
||||
- `indicatif`: Progress indicators (for future use)
|
||||
323
crates/cli/src/client.rs
Normal file
323
crates/cli/src/client.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::{Client as HttpClient, Method, RequestBuilder, Response, StatusCode};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::CliConfig;
|
||||
|
||||
/// API client for interacting with Attune API
|
||||
pub struct ApiClient {
|
||||
client: HttpClient,
|
||||
base_url: String,
|
||||
auth_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Standard API response wrapper
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
/// API error response
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct ApiError {
|
||||
pub error: String,
|
||||
#[serde(default)]
|
||||
pub _details: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
/// Create a new API client from configuration
|
||||
pub fn from_config(config: &CliConfig, api_url_override: &Option<String>) -> Self {
|
||||
let base_url = config.effective_api_url(api_url_override);
|
||||
let auth_token = config.auth_token().ok().flatten();
|
||||
let refresh_token = config.refresh_token().ok().flatten();
|
||||
let config_path = CliConfig::config_path().ok();
|
||||
|
||||
Self {
|
||||
client: HttpClient::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client"),
|
||||
base_url,
|
||||
auth_token,
|
||||
refresh_token,
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new API client
|
||||
#[cfg(test)]
|
||||
pub fn new(base_url: String, auth_token: Option<String>) -> Self {
|
||||
let client = HttpClient::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client");
|
||||
|
||||
Self {
|
||||
client,
|
||||
base_url,
|
||||
auth_token,
|
||||
refresh_token: None,
|
||||
config_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the authentication token
|
||||
#[cfg(test)]
|
||||
pub fn set_auth_token(&mut self, token: String) {
|
||||
self.auth_token = Some(token);
|
||||
}
|
||||
|
||||
/// Clear the authentication token
|
||||
#[cfg(test)]
|
||||
pub fn clear_auth_token(&mut self) {
|
||||
self.auth_token = None;
|
||||
}
|
||||
|
||||
/// Refresh the authentication token using the refresh token
|
||||
///
|
||||
/// Returns Ok(true) if refresh succeeded, Ok(false) if no refresh token available
|
||||
async fn refresh_auth_token(&mut self) -> Result<bool> {
|
||||
let refresh_token = match &self.refresh_token {
|
||||
Some(token) => token.clone(),
|
||||
None => return Ok(false), // No refresh token available
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest {
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
// Build refresh request without auth token
|
||||
let url = format!("{}/auth/refresh", self.base_url);
|
||||
let req = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&RefreshRequest { refresh_token });
|
||||
|
||||
let response = req.send().await.context("Failed to refresh token")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
// Refresh failed - clear tokens
|
||||
self.auth_token = None;
|
||||
self.refresh_token = None;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let api_response: ApiResponse<TokenResponse> = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse refresh response")?;
|
||||
|
||||
// Update in-memory tokens
|
||||
self.auth_token = Some(api_response.data.access_token.clone());
|
||||
self.refresh_token = Some(api_response.data.refresh_token.clone());
|
||||
|
||||
// Persist to config file if we have the path
|
||||
if self.config_path.is_some() {
|
||||
if let Ok(mut config) = CliConfig::load() {
|
||||
let _ = config.set_auth(
|
||||
api_response.data.access_token,
|
||||
api_response.data.refresh_token,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Build a request with common headers
|
||||
fn build_request(&self, method: Method, path: &str) -> RequestBuilder {
|
||||
// Auth endpoints are at /auth, not /auth
|
||||
let url = if path.starts_with("/auth") {
|
||||
format!("{}{}", self.base_url, path)
|
||||
} else {
|
||||
format!("{}/api/v1{}", self.base_url, path)
|
||||
};
|
||||
let mut req = self.client.request(method, &url);
|
||||
|
||||
if let Some(token) = &self.auth_token {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
|
||||
req
|
||||
}
|
||||
|
||||
/// Execute a request and handle the response with automatic token refresh
|
||||
async fn execute<T: DeserializeOwned>(&mut self, req: RequestBuilder) -> Result<T> {
|
||||
let response = req.send().await.context("Failed to send request to API")?;
|
||||
|
||||
// If 401 and we have a refresh token, try to refresh once
|
||||
if response.status() == StatusCode::UNAUTHORIZED && self.refresh_token.is_some() {
|
||||
// Try to refresh the token
|
||||
if self.refresh_auth_token().await? {
|
||||
// Rebuild and retry the original request with new token
|
||||
// Note: This is a simplified retry - the original request body is already consumed
|
||||
// For a production implementation, we'd need to clone the request or store the body
|
||||
return Err(anyhow::anyhow!(
|
||||
"Token expired and was refreshed. Please retry your command."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.handle_response(response).await
|
||||
}
|
||||
|
||||
/// Handle API response and extract data
|
||||
async fn handle_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
let api_response: ApiResponse<T> = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse API response")?;
|
||||
Ok(api_response.data)
|
||||
} else {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Try to parse as API error
|
||||
if let Ok(api_error) = serde_json::from_str::<ApiError>(&error_text) {
|
||||
anyhow::bail!("API error ({}): {}", status, api_error.error);
|
||||
} else {
|
||||
anyhow::bail!("API error ({}): {}", status, error_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET request
|
||||
pub async fn get<T: DeserializeOwned>(&mut self, path: &str) -> Result<T> {
|
||||
let req = self.build_request(Method::GET, path);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// GET request with query parameters (query string must be in path)
|
||||
///
|
||||
/// Part of REST client API - reserved for future advanced filtering/search features.
|
||||
/// Example: `client.get_with_query("/actions?enabled=true&pack=core").await`
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_with_query<T: DeserializeOwned>(&mut self, path: &str) -> Result<T> {
|
||||
let req = self.build_request(Method::GET, path);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// POST request with JSON body
|
||||
pub async fn post<T: DeserializeOwned, B: Serialize>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T> {
|
||||
let req = self.build_request(Method::POST, path).json(body);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// PUT request with JSON body
|
||||
///
|
||||
/// Part of REST client API - will be used for update operations
|
||||
pub async fn put<T: DeserializeOwned, B: Serialize>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T> {
|
||||
let req = self.build_request(Method::PUT, path).json(body);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// PATCH request with JSON body
|
||||
pub async fn patch<T: DeserializeOwned, B: Serialize>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T> {
|
||||
let req = self.build_request(Method::PATCH, path).json(body);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// DELETE request with response parsing
|
||||
///
|
||||
/// Part of REST client API - reserved for delete operations that return data.
|
||||
/// Currently we use `delete_no_response()` for all delete operations.
|
||||
/// This method is kept for API completeness and future use cases where
|
||||
/// delete operations return metadata (e.g., cascade deletion summaries).
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete<T: DeserializeOwned>(&mut self, path: &str) -> Result<T> {
|
||||
let req = self.build_request(Method::DELETE, path);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// POST request without expecting response body
|
||||
///
|
||||
/// Part of REST client API - reserved for fire-and-forget operations.
|
||||
/// Example use cases: webhook notifications, event submissions, audit logging.
|
||||
/// Kept for API completeness even though not currently used.
|
||||
#[allow(dead_code)]
|
||||
pub async fn post_no_response<B: Serialize>(&mut self, path: &str, body: &B) -> Result<()> {
|
||||
let req = self.build_request(Method::POST, path).json(body);
|
||||
let response = req.send().await.context("Failed to send request to API")?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
anyhow::bail!("API error ({}): {}", status, error_text);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE request without expecting response body
|
||||
pub async fn delete_no_response(&mut self, path: &str) -> Result<()> {
|
||||
let req = self.build_request(Method::DELETE, path);
|
||||
let response = req.send().await.context("Failed to send request to API")?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
anyhow::bail!("API error ({}): {}", status, error_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_creation() {
|
||||
let client = ApiClient::new("http://localhost:8080".to_string(), None);
|
||||
assert_eq!(client.base_url, "http://localhost:8080");
|
||||
assert!(client.auth_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_auth_token() {
|
||||
let mut client = ApiClient::new("http://localhost:8080".to_string(), None);
|
||||
assert!(client.auth_token.is_none());
|
||||
|
||||
client.set_auth_token("test_token".to_string());
|
||||
assert_eq!(client.auth_token, Some("test_token".to_string()));
|
||||
|
||||
client.clear_auth_token();
|
||||
assert!(client.auth_token.is_none());
|
||||
}
|
||||
}
|
||||
521
crates/cli/src/commands/action.rs
Normal file
521
crates/cli/src/commands/action.rs
Normal file
@@ -0,0 +1,521 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ActionCommands {
|
||||
/// List all actions
|
||||
List {
|
||||
/// Filter by pack name
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
|
||||
/// Filter by action name
|
||||
#[arg(short, long)]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Show details of a specific action
|
||||
Show {
|
||||
/// Action reference (pack.action or ID)
|
||||
action_ref: String,
|
||||
},
|
||||
/// Update an action
|
||||
Update {
|
||||
/// Action reference (pack.action or ID)
|
||||
action_ref: String,
|
||||
|
||||
/// Update label
|
||||
#[arg(long)]
|
||||
label: Option<String>,
|
||||
|
||||
/// Update description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
|
||||
/// Update entrypoint
|
||||
#[arg(long)]
|
||||
entrypoint: Option<String>,
|
||||
|
||||
/// Update runtime ID
|
||||
#[arg(long)]
|
||||
runtime: Option<i64>,
|
||||
},
|
||||
/// Delete an action
|
||||
Delete {
|
||||
/// Action reference (pack.action or ID)
|
||||
action_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short, long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Execute an action
|
||||
Execute {
|
||||
/// Action reference (pack.action or ID)
|
||||
action_ref: String,
|
||||
|
||||
/// Action parameters in key=value format
|
||||
#[arg(long)]
|
||||
param: Vec<String>,
|
||||
|
||||
/// Parameters as JSON string
|
||||
#[arg(long, conflicts_with = "param")]
|
||||
params_json: Option<String>,
|
||||
|
||||
/// Wait for execution to complete
|
||||
#[arg(short, long)]
|
||||
wait: bool,
|
||||
|
||||
/// Timeout in seconds when waiting (default: 300)
|
||||
#[arg(long, default_value = "300", requires = "wait")]
|
||||
timeout: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Action {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
action_ref: String,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
entrypoint: String,
|
||||
runtime: Option<i64>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ActionDetail {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
action_ref: String,
|
||||
pack: i64,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
entrypoint: String,
|
||||
runtime: Option<i64>,
|
||||
param_schema: Option<serde_json::Value>,
|
||||
out_schema: Option<serde_json::Value>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateActionRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
entrypoint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
runtime: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ExecuteActionRequest {
|
||||
action_ref: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Execution {
|
||||
id: i64,
|
||||
action: Option<i64>,
|
||||
action_ref: String,
|
||||
config: Option<serde_json::Value>,
|
||||
parent: Option<i64>,
|
||||
enforcement: Option<i64>,
|
||||
executor: Option<i64>,
|
||||
status: String,
|
||||
result: Option<serde_json::Value>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
pub async fn handle_action_command(
|
||||
profile: &Option<String>,
|
||||
command: ActionCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
ActionCommands::List { pack, name } => {
|
||||
handle_list(pack, name, profile, api_url, output_format).await
|
||||
}
|
||||
ActionCommands::Show { action_ref } => {
|
||||
handle_show(action_ref, profile, api_url, output_format).await
|
||||
}
|
||||
ActionCommands::Update {
|
||||
action_ref,
|
||||
label,
|
||||
description,
|
||||
entrypoint,
|
||||
runtime,
|
||||
} => {
|
||||
handle_update(
|
||||
action_ref,
|
||||
label,
|
||||
description,
|
||||
entrypoint,
|
||||
runtime,
|
||||
profile,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ActionCommands::Delete { action_ref, yes } => {
|
||||
handle_delete(action_ref, yes, profile, api_url, output_format).await
|
||||
}
|
||||
ActionCommands::Execute {
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
wait,
|
||||
timeout,
|
||||
} => {
|
||||
handle_execute(
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
profile,
|
||||
api_url,
|
||||
wait,
|
||||
timeout,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
pack: Option<String>,
|
||||
name: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Use pack-specific endpoint if pack filter is specified
|
||||
let path = if let Some(pack_ref) = pack {
|
||||
format!("/packs/{}/actions", pack_ref)
|
||||
} else {
|
||||
"/actions".to_string()
|
||||
};
|
||||
|
||||
let mut actions: Vec<Action> = client.get(&path).await?;
|
||||
|
||||
// Filter by name if specified (client-side filtering)
|
||||
if let Some(action_name) = name {
|
||||
actions.retain(|a| a.action_ref.contains(&action_name));
|
||||
}
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&actions, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if actions.is_empty() {
|
||||
output::print_info("No actions found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Pack", "Name", "Runner", "Enabled", "Description"],
|
||||
);
|
||||
|
||||
for action in actions {
|
||||
table.add_row(vec![
|
||||
action.id.to_string(),
|
||||
action.pack_ref.clone(),
|
||||
action.action_ref.clone(),
|
||||
action
|
||||
.runtime
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
"✓".to_string(),
|
||||
output::truncate(&action.description, 40),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show(
|
||||
action_ref: String,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/actions/{}", action_ref);
|
||||
let action: ActionDetail = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&action, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Action: {}", action.action_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", action.id.to_string()),
|
||||
("Reference", action.action_ref.clone()),
|
||||
("Pack", action.pack_ref.clone()),
|
||||
("Label", action.label.clone()),
|
||||
("Description", action.description.clone()),
|
||||
("Entry Point", action.entrypoint.clone()),
|
||||
(
|
||||
"Runtime",
|
||||
action
|
||||
.runtime
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Created", output::format_timestamp(&action.created)),
|
||||
("Updated", output::format_timestamp(&action.updated)),
|
||||
]);
|
||||
|
||||
if let Some(params) = action.param_schema {
|
||||
if !params.is_null() {
|
||||
output::print_section("Parameters Schema");
|
||||
println!("{}", serde_json::to_string_pretty(¶ms)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_update(
|
||||
action_ref: String,
|
||||
label: Option<String>,
|
||||
description: Option<String>,
|
||||
entrypoint: Option<String>,
|
||||
runtime: Option<i64>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Check that at least one field is provided
|
||||
if label.is_none() && description.is_none() && entrypoint.is_none() && runtime.is_none() {
|
||||
anyhow::bail!("At least one field must be provided to update");
|
||||
}
|
||||
|
||||
let request = UpdateActionRequest {
|
||||
label,
|
||||
description,
|
||||
entrypoint,
|
||||
runtime,
|
||||
};
|
||||
|
||||
let path = format!("/actions/{}", action_ref);
|
||||
let action: ActionDetail = client.put(&path, &request).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&action, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!(
|
||||
"Action '{}' updated successfully",
|
||||
action.action_ref
|
||||
));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", action.id.to_string()),
|
||||
("Ref", action.action_ref.clone()),
|
||||
("Pack", action.pack_ref.clone()),
|
||||
("Label", action.label.clone()),
|
||||
("Description", action.description.clone()),
|
||||
("Entrypoint", action.entrypoint.clone()),
|
||||
(
|
||||
"Runtime",
|
||||
action
|
||||
.runtime
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Updated", output::format_timestamp(&action.updated)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete(
|
||||
action_ref: String,
|
||||
yes: bool,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Confirm deletion unless --yes is provided
|
||||
if !yes && matches!(output_format, OutputFormat::Table) {
|
||||
let confirm = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to delete action '{}'?",
|
||||
action_ref
|
||||
))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output::print_info("Delete cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let path = format!("/actions/{}", action_ref);
|
||||
client.delete_no_response(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let msg = serde_json::json!({"message": "Action deleted successfully"});
|
||||
output::print_output(&msg, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Action '{}' deleted successfully", action_ref));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_execute(
|
||||
action_ref: String,
|
||||
params: Vec<String>,
|
||||
params_json: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
wait: bool,
|
||||
timeout: u64,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Parse parameters
|
||||
let parameters = if let Some(json_str) = params_json {
|
||||
serde_json::from_str(&json_str)?
|
||||
} else if !params.is_empty() {
|
||||
let mut map = HashMap::new();
|
||||
for p in params {
|
||||
let parts: Vec<&str> = p.splitn(2, '=').collect();
|
||||
if parts.len() != 2 {
|
||||
anyhow::bail!("Invalid parameter format: '{}'. Expected key=value", p);
|
||||
}
|
||||
// Try to parse as JSON value, fall back to string
|
||||
let value: serde_json::Value = serde_json::from_str(parts[1])
|
||||
.unwrap_or_else(|_| serde_json::Value::String(parts[1].to_string()));
|
||||
map.insert(parts[0].to_string(), value);
|
||||
}
|
||||
serde_json::to_value(map)?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
let request = ExecuteActionRequest {
|
||||
action_ref: action_ref.clone(),
|
||||
parameters,
|
||||
};
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!("Executing action: {}", action_ref));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let path = "/executions/execute".to_string();
|
||||
let mut execution: Execution = client.post(&path, &request).await?;
|
||||
|
||||
if wait {
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!(
|
||||
"Waiting for execution {} to complete...",
|
||||
execution.id
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Poll for completion
|
||||
let start = std::time::Instant::now();
|
||||
let timeout_duration = std::time::Duration::from_secs(timeout);
|
||||
|
||||
loop {
|
||||
if start.elapsed() > timeout_duration {
|
||||
anyhow::bail!("Execution timed out after {} seconds", timeout);
|
||||
}
|
||||
|
||||
let exec_path = format!("/executions/{}", execution.id);
|
||||
execution = client.get(&exec_path).await?;
|
||||
|
||||
if execution.status == "succeeded"
|
||||
|| execution.status == "failed"
|
||||
|| execution.status == "canceled"
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&execution, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!(
|
||||
"Execution {} {}",
|
||||
execution.id,
|
||||
if wait { "completed" } else { "started" }
|
||||
));
|
||||
output::print_section("Execution Details");
|
||||
output::print_key_value_table(vec![
|
||||
("Execution ID", execution.id.to_string()),
|
||||
("Action", execution.action_ref.clone()),
|
||||
("Status", output::format_status(&execution.status)),
|
||||
("Created", output::format_timestamp(&execution.created)),
|
||||
("Updated", output::format_timestamp(&execution.updated)),
|
||||
]);
|
||||
|
||||
if let Some(result) = execution.result {
|
||||
if !result.is_null() {
|
||||
output::print_section("Result");
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
213
crates/cli/src/commands/auth.rs
Normal file
213
crates/cli/src/commands/auth.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum AuthCommands {
|
||||
/// Log in to Attune API
|
||||
Login {
|
||||
/// Username or email
|
||||
#[arg(short, long)]
|
||||
username: String,
|
||||
|
||||
/// Password (will prompt if not provided)
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
},
|
||||
/// Log out and clear authentication tokens
|
||||
Logout,
|
||||
/// Show current authentication status
|
||||
Whoami,
|
||||
/// Refresh authentication token
|
||||
Refresh,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LoginRequest {
|
||||
login: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LoginResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
expires_in: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Identity {
|
||||
id: i64,
|
||||
login: String,
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn handle_auth_command(
|
||||
profile: &Option<String>,
|
||||
command: AuthCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
AuthCommands::Login { username, password } => {
|
||||
handle_login(username, password, profile, api_url, output_format).await
|
||||
}
|
||||
AuthCommands::Logout => handle_logout(profile, output_format).await,
|
||||
AuthCommands::Whoami => handle_whoami(profile, api_url, output_format).await,
|
||||
AuthCommands::Refresh => handle_refresh(profile, api_url, output_format).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_login(
|
||||
username: String,
|
||||
password: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
|
||||
// Prompt for password if not provided
|
||||
let password = match password {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let pw = dialoguer::Password::new()
|
||||
.with_prompt("Password")
|
||||
.interact()?;
|
||||
pw
|
||||
}
|
||||
};
|
||||
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let login_req = LoginRequest {
|
||||
login: username,
|
||||
password,
|
||||
};
|
||||
|
||||
let response: LoginResponse = client.post("/auth/login", &login_req).await?;
|
||||
|
||||
// Save tokens to config
|
||||
let mut config = CliConfig::load()?;
|
||||
config.set_auth(
|
||||
response.access_token.clone(),
|
||||
response.refresh_token.clone(),
|
||||
)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&response, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success("Successfully logged in");
|
||||
output::print_info(&format!("Token expires in {} seconds", response.expires_in));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_logout(profile: &Option<String>, output_format: OutputFormat) -> Result<()> {
|
||||
let mut config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
config.clear_auth()?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let msg = serde_json::json!({"message": "Successfully logged out"});
|
||||
output::print_output(&msg, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success("Successfully logged out");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_whoami(
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
|
||||
if config.auth_token().ok().flatten().is_none() {
|
||||
anyhow::bail!("Not logged in. Use 'attune auth login' to authenticate.");
|
||||
}
|
||||
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let identity: Identity = client.get("/auth/me").await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&identity, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section("Current Identity");
|
||||
output::print_key_value_table(vec![
|
||||
("ID", identity.id.to_string()),
|
||||
("Login", identity.login),
|
||||
(
|
||||
"Display Name",
|
||||
identity.display_name.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_refresh(
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
|
||||
// Check if we have a refresh token
|
||||
let refresh_token = config
|
||||
.refresh_token()
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| anyhow::anyhow!("No refresh token found. Please log in again."))?;
|
||||
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest {
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
// Call the refresh endpoint
|
||||
let response: LoginResponse = client
|
||||
.post("/auth/refresh", &RefreshRequest { refresh_token })
|
||||
.await?;
|
||||
|
||||
// Save new tokens to config
|
||||
let mut config = CliConfig::load()?;
|
||||
config.set_auth(
|
||||
response.access_token.clone(),
|
||||
response.refresh_token.clone(),
|
||||
)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&response, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success("Token refreshed successfully");
|
||||
output::print_info(&format!(
|
||||
"New token expires in {} seconds",
|
||||
response.expires_in
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
354
crates/cli/src/commands/config.rs
Normal file
354
crates/cli/src/commands/config.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Subcommand;
|
||||
use colored::Colorize;
|
||||
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ConfigCommands {
|
||||
/// List all configuration values
|
||||
List,
|
||||
/// Get a configuration value
|
||||
Get {
|
||||
/// Configuration key
|
||||
key: String,
|
||||
},
|
||||
/// Set a configuration value
|
||||
Set {
|
||||
/// Configuration key
|
||||
key: String,
|
||||
/// Configuration value
|
||||
value: String,
|
||||
},
|
||||
/// Show the configuration file path
|
||||
Path,
|
||||
/// List all profiles
|
||||
Profiles,
|
||||
/// Show current profile
|
||||
Current,
|
||||
/// Switch to a different profile
|
||||
Use {
|
||||
/// Profile name
|
||||
name: String,
|
||||
},
|
||||
/// Add or update a profile
|
||||
AddProfile {
|
||||
/// Profile name
|
||||
name: String,
|
||||
/// API URL
|
||||
#[arg(short, long)]
|
||||
api_url: String,
|
||||
/// Description
|
||||
#[arg(short, long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
/// Remove a profile
|
||||
RemoveProfile {
|
||||
/// Profile name
|
||||
name: String,
|
||||
},
|
||||
/// Show profile details
|
||||
ShowProfile {
|
||||
/// Profile name
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn handle_config_command(
|
||||
_profile: &Option<String>,
|
||||
command: ConfigCommands,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
ConfigCommands::List => handle_list(output_format).await,
|
||||
ConfigCommands::Get { key } => handle_get(key, output_format).await,
|
||||
ConfigCommands::Set { key, value } => handle_set(key, value, output_format).await,
|
||||
ConfigCommands::Path => handle_path(output_format).await,
|
||||
ConfigCommands::Profiles => handle_profiles(output_format).await,
|
||||
ConfigCommands::Current => handle_current(output_format).await,
|
||||
ConfigCommands::Use { name } => handle_use(name, output_format).await,
|
||||
ConfigCommands::AddProfile {
|
||||
name,
|
||||
api_url,
|
||||
description,
|
||||
} => handle_add_profile(name, api_url, description, output_format).await,
|
||||
ConfigCommands::RemoveProfile { name } => handle_remove_profile(name, output_format).await,
|
||||
ConfigCommands::ShowProfile { name } => handle_show_profile(name, output_format).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(output_format: OutputFormat) -> Result<()> {
|
||||
let config = CliConfig::load()?; // Config commands always use default profile
|
||||
let all_config = config.list_all();
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json => {
|
||||
let map: std::collections::HashMap<String, String> = all_config.into_iter().collect();
|
||||
output::print_output(&map, output_format)?;
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
let map: std::collections::HashMap<String, String> = all_config.into_iter().collect();
|
||||
output::print_output(&map, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section("Configuration");
|
||||
let pairs: Vec<(&str, String)> = all_config
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.clone()))
|
||||
.collect();
|
||||
output::print_key_value_table(pairs);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_get(key: String, output_format: OutputFormat) -> Result<()> {
|
||||
let config = CliConfig::load()?; // Config commands always use default profile
|
||||
let value = config.get_value(&key)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"key": key,
|
||||
"value": value
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!("{}", value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_profiles(output_format: OutputFormat) -> Result<()> {
|
||||
let config = CliConfig::load()?; // Config commands always use default profile
|
||||
let profiles = config.list_profiles();
|
||||
let current = &config.current_profile;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json => {
|
||||
let data: Vec<_> = profiles
|
||||
.iter()
|
||||
.map(|name| {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"current": name == current
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
output::print_output(&data, output_format)?;
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
let data: Vec<_> = profiles
|
||||
.iter()
|
||||
.map(|name| {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"current": name == current
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
output::print_output(&data, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section("Profiles");
|
||||
for name in profiles {
|
||||
if name == *current {
|
||||
println!(" • {} (active)", name.bright_green().bold());
|
||||
} else {
|
||||
println!(" • {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_current(output_format: OutputFormat) -> Result<()> {
|
||||
let config = CliConfig::load()?; // Config commands always use default profile
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"current_profile": config.current_profile
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!("{}", config.current_profile);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_use(name: String, output_format: OutputFormat) -> Result<()> {
|
||||
let mut config = CliConfig::load()?;
|
||||
config.switch_profile(name.clone())?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"current_profile": name,
|
||||
"message": "Switched profile"
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Switched to profile '{}'", name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_add_profile(
|
||||
name: String,
|
||||
api_url: String,
|
||||
description: Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
use crate::config::Profile;
|
||||
|
||||
let mut config = CliConfig::load()?;
|
||||
|
||||
let profile = Profile {
|
||||
api_url: api_url.clone(),
|
||||
auth_token: None,
|
||||
refresh_token: None,
|
||||
output_format: None,
|
||||
description,
|
||||
};
|
||||
|
||||
config.set_profile(name.clone(), profile)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"profile": name,
|
||||
"api_url": api_url,
|
||||
"message": "Profile added"
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Profile '{}' added", name));
|
||||
output::print_info(&format!("API URL: {}", api_url));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_remove_profile(name: String, output_format: OutputFormat) -> Result<()> {
|
||||
let mut config = CliConfig::load()?;
|
||||
config.remove_profile(&name)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"profile": name,
|
||||
"message": "Profile removed"
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Profile '{}' removed", name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show_profile(name: String, output_format: OutputFormat) -> Result<()> {
|
||||
let config = CliConfig::load()?; // Config commands always use default profile
|
||||
let profile = config
|
||||
.get_profile(&name)
|
||||
.context(format!("Profile '{}' not found", name))?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&profile, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Profile: {}", name));
|
||||
let mut pairs = vec![
|
||||
("API URL", profile.api_url.clone()),
|
||||
(
|
||||
"Auth Token",
|
||||
profile
|
||||
.auth_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
"Refresh Token",
|
||||
profile
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(output_format) = &profile.output_format {
|
||||
pairs.push(("Output Format", output_format.clone()));
|
||||
}
|
||||
|
||||
if let Some(description) = &profile.description {
|
||||
pairs.push(("Description", description.clone()));
|
||||
}
|
||||
|
||||
output::print_key_value_table(pairs);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_set(key: String, value: String, output_format: OutputFormat) -> Result<()> {
|
||||
let mut config = CliConfig::load()?;
|
||||
config.set_value(&key, value.clone())?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"key": key,
|
||||
"value": value,
|
||||
"message": "Configuration updated"
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!("Configuration updated: {} = {}", key, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_path(output_format: OutputFormat) -> Result<()> {
|
||||
let path = CliConfig::config_path()?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"path": path.to_string_lossy()
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!("{}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
445
crates/cli/src/commands/execution.rs
Normal file
445
crates/cli/src/commands/execution.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ExecutionCommands {
|
||||
/// List all executions
|
||||
List {
|
||||
/// Filter by pack name
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
|
||||
/// Filter by action name
|
||||
#[arg(short, long)]
|
||||
action: Option<String>,
|
||||
|
||||
/// Filter by status
|
||||
#[arg(short, long)]
|
||||
status: Option<String>,
|
||||
|
||||
/// Search in execution result (case-insensitive)
|
||||
#[arg(short, long)]
|
||||
result: Option<String>,
|
||||
|
||||
/// Limit number of results
|
||||
#[arg(short, long, default_value = "50")]
|
||||
limit: i32,
|
||||
},
|
||||
/// Show details of a specific execution
|
||||
Show {
|
||||
/// Execution ID
|
||||
execution_id: i64,
|
||||
},
|
||||
/// Show execution logs
|
||||
Logs {
|
||||
/// Execution ID
|
||||
execution_id: i64,
|
||||
|
||||
/// Follow log output
|
||||
#[arg(short, long)]
|
||||
follow: bool,
|
||||
},
|
||||
/// Cancel a running execution
|
||||
Cancel {
|
||||
/// Execution ID
|
||||
execution_id: i64,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Get raw execution result
|
||||
Result {
|
||||
/// Execution ID
|
||||
execution_id: i64,
|
||||
|
||||
/// Output format (json or yaml, default: json)
|
||||
#[arg(short = 'f', long, value_enum, default_value = "json")]
|
||||
format: ResultFormat,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
pub enum ResultFormat {
|
||||
Json,
|
||||
Yaml,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Execution {
|
||||
id: i64,
|
||||
action_ref: String,
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
parent: Option<i64>,
|
||||
#[serde(default)]
|
||||
enforcement: Option<i64>,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
created: String,
|
||||
#[serde(default)]
|
||||
updated: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ExecutionDetail {
|
||||
id: i64,
|
||||
#[serde(default)]
|
||||
action: Option<i64>,
|
||||
action_ref: String,
|
||||
#[serde(default)]
|
||||
config: Option<serde_json::Value>,
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
parent: Option<i64>,
|
||||
#[serde(default)]
|
||||
enforcement: Option<i64>,
|
||||
#[serde(default)]
|
||||
executor: Option<i64>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ExecutionLogs {
|
||||
execution_id: i64,
|
||||
logs: Vec<LogEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LogEntry {
|
||||
timestamp: String,
|
||||
level: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub async fn handle_execution_command(
|
||||
profile: &Option<String>,
|
||||
command: ExecutionCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
ExecutionCommands::List {
|
||||
pack,
|
||||
action,
|
||||
status,
|
||||
result,
|
||||
limit,
|
||||
} => {
|
||||
handle_list(
|
||||
profile,
|
||||
pack,
|
||||
action,
|
||||
status,
|
||||
result,
|
||||
limit,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ExecutionCommands::Show { execution_id } => {
|
||||
handle_show(profile, execution_id, api_url, output_format).await
|
||||
}
|
||||
ExecutionCommands::Logs {
|
||||
execution_id,
|
||||
follow,
|
||||
} => handle_logs(profile, execution_id, follow, api_url, output_format).await,
|
||||
ExecutionCommands::Cancel { execution_id, yes } => {
|
||||
handle_cancel(profile, execution_id, yes, api_url, output_format).await
|
||||
}
|
||||
ExecutionCommands::Result {
|
||||
execution_id,
|
||||
format,
|
||||
} => handle_result(profile, execution_id, format, api_url).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
profile: &Option<String>,
|
||||
pack: Option<String>,
|
||||
action: Option<String>,
|
||||
status: Option<String>,
|
||||
result: Option<String>,
|
||||
limit: i32,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let mut query_params = vec![format!("per_page={}", limit)];
|
||||
if let Some(pack_name) = pack {
|
||||
query_params.push(format!("pack_name={}", pack_name));
|
||||
}
|
||||
if let Some(action_name) = action {
|
||||
query_params.push(format!("action_ref={}", action_name));
|
||||
}
|
||||
if let Some(status_filter) = status {
|
||||
query_params.push(format!("status={}", status_filter));
|
||||
}
|
||||
if let Some(result_search) = result {
|
||||
query_params.push(format!(
|
||||
"result_contains={}",
|
||||
urlencoding::encode(&result_search)
|
||||
));
|
||||
}
|
||||
|
||||
let path = format!("/executions?{}", query_params.join("&"));
|
||||
let executions: Vec<Execution> = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&executions, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if executions.is_empty() {
|
||||
output::print_info("No executions found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Action", "Status", "Started", "Duration"],
|
||||
);
|
||||
|
||||
for execution in executions {
|
||||
table.add_row(vec![
|
||||
execution.id.to_string(),
|
||||
execution.action_ref.clone(),
|
||||
output::format_status(&execution.status),
|
||||
output::format_timestamp(&execution.created),
|
||||
"-".to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show(
|
||||
profile: &Option<String>,
|
||||
execution_id: i64,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/executions/{}", execution_id);
|
||||
let execution: ExecutionDetail = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&execution, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Execution: {}", execution.id));
|
||||
|
||||
output::print_key_value_table(vec![
|
||||
("ID", execution.id.to_string()),
|
||||
("Action", execution.action_ref.clone()),
|
||||
("Status", output::format_status(&execution.status)),
|
||||
(
|
||||
"Parent ID",
|
||||
execution
|
||||
.parent
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
(
|
||||
"Enforcement ID",
|
||||
execution
|
||||
.enforcement
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
(
|
||||
"Executor ID",
|
||||
execution
|
||||
.executor
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Created", output::format_timestamp(&execution.created)),
|
||||
("Updated", output::format_timestamp(&execution.updated)),
|
||||
]);
|
||||
|
||||
if let Some(config) = execution.config {
|
||||
if !config.is_null() {
|
||||
output::print_section("Configuration");
|
||||
println!("{}", serde_json::to_string_pretty(&config)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(result) = execution.result {
|
||||
if !result.is_null() {
|
||||
output::print_section("Result");
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_logs(
|
||||
profile: &Option<String>,
|
||||
execution_id: i64,
|
||||
follow: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/executions/{}/logs", execution_id);
|
||||
|
||||
if follow {
|
||||
// Polling implementation for following logs
|
||||
let mut last_count = 0;
|
||||
loop {
|
||||
let logs: ExecutionLogs = client.get(&path).await?;
|
||||
|
||||
// Print new logs only
|
||||
for log in logs.logs.iter().skip(last_count) {
|
||||
match output_format {
|
||||
OutputFormat::Json => {
|
||||
println!("{}", serde_json::to_string(log)?);
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
println!("{}", serde_yaml_ng::to_string(log)?);
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!(
|
||||
"[{}] [{}] {}",
|
||||
output::format_timestamp(&log.timestamp),
|
||||
log.level.to_uppercase(),
|
||||
log.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_count = logs.logs.len();
|
||||
|
||||
// Check if execution is complete
|
||||
let exec_path = format!("/executions/{}", execution_id);
|
||||
let execution: ExecutionDetail = client.get(&exec_path).await?;
|
||||
let status_lower = execution.status.to_lowercase();
|
||||
if status_lower == "succeeded" || status_lower == "failed" || status_lower == "canceled"
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
} else {
|
||||
let logs: ExecutionLogs = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&logs, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if logs.logs.is_empty() {
|
||||
output::print_info("No logs available");
|
||||
} else {
|
||||
for log in logs.logs {
|
||||
println!(
|
||||
"[{}] [{}] {}",
|
||||
output::format_timestamp(&log.timestamp),
|
||||
log.level.to_uppercase(),
|
||||
log.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_result(
|
||||
profile: &Option<String>,
|
||||
execution_id: i64,
|
||||
format: ResultFormat,
|
||||
api_url: &Option<String>,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/executions/{}", execution_id);
|
||||
let execution: ExecutionDetail = client.get(&path).await?;
|
||||
|
||||
// Check if execution has a result
|
||||
if let Some(result) = execution.result {
|
||||
// Output raw result in requested format
|
||||
match format {
|
||||
ResultFormat::Json => {
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
}
|
||||
ResultFormat::Yaml => {
|
||||
println!("{}", serde_yaml_ng::to_string(&result)?);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!("Execution {} has no result yet", execution_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_cancel(
|
||||
profile: &Option<String>,
|
||||
execution_id: i64,
|
||||
yes: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Confirm cancellation unless --yes is provided
|
||||
if !yes && matches!(output_format, OutputFormat::Table) {
|
||||
let confirm = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to cancel execution {}?",
|
||||
execution_id
|
||||
))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output::print_info("Cancellation aborted");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let path = format!("/executions/{}/cancel", execution_id);
|
||||
let execution: ExecutionDetail = client.post(&path, &serde_json::json!({})).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&execution, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Execution {} cancelled", execution_id));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
9
crates/cli/src/commands/mod.rs
Normal file
9
crates/cli/src/commands/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod action;
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod execution;
|
||||
pub mod pack;
|
||||
pub mod pack_index;
|
||||
pub mod rule;
|
||||
pub mod sensor;
|
||||
pub mod trigger;
|
||||
1427
crates/cli/src/commands/pack.rs
Normal file
1427
crates/cli/src/commands/pack.rs
Normal file
File diff suppressed because it is too large
Load Diff
387
crates/cli/src/commands/pack_index.rs
Normal file
387
crates/cli/src/commands/pack_index.rs
Normal file
@@ -0,0 +1,387 @@
|
||||
//! Pack registry index management utilities
|
||||
|
||||
use crate::output::{self, OutputFormat};
|
||||
use anyhow::Result;
|
||||
use attune_common::pack_registry::calculate_directory_checksum;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Update a registry index file with a new pack entry
|
||||
pub async fn handle_index_update(
|
||||
index_path: String,
|
||||
pack_path: String,
|
||||
git_url: Option<String>,
|
||||
git_ref: Option<String>,
|
||||
archive_url: Option<String>,
|
||||
update: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
// Load existing index
|
||||
let index_file_path = Path::new(&index_path);
|
||||
if !index_file_path.exists() {
|
||||
return Err(anyhow::anyhow!("Index file not found: {}", index_path));
|
||||
}
|
||||
|
||||
let index_content = fs::read_to_string(index_file_path)?;
|
||||
let mut index: JsonValue = serde_json::from_str(&index_content)?;
|
||||
|
||||
// Get packs array (or create it)
|
||||
let packs = index
|
||||
.get_mut("packs")
|
||||
.and_then(|p| p.as_array_mut())
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid index format: missing 'packs' array"))?;
|
||||
|
||||
// Load pack.yaml from the pack directory
|
||||
let pack_dir = Path::new(&pack_path);
|
||||
if !pack_dir.exists() || !pack_dir.is_dir() {
|
||||
return Err(anyhow::anyhow!("Pack directory not found: {}", pack_path));
|
||||
}
|
||||
|
||||
let pack_yaml_path = pack_dir.join("pack.yaml");
|
||||
if !pack_yaml_path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"pack.yaml not found in directory: {}",
|
||||
pack_path
|
||||
));
|
||||
}
|
||||
|
||||
let pack_yaml_content = fs::read_to_string(&pack_yaml_path)?;
|
||||
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
|
||||
|
||||
// Extract pack metadata
|
||||
let pack_ref = pack_yaml
|
||||
.get("ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'ref' field in pack.yaml"))?;
|
||||
|
||||
let version = pack_yaml
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'version' field in pack.yaml"))?;
|
||||
|
||||
// Check if pack already exists in index
|
||||
let existing_index = packs
|
||||
.iter()
|
||||
.position(|p| p.get("ref").and_then(|r| r.as_str()) == Some(pack_ref));
|
||||
|
||||
if let Some(_idx) = existing_index {
|
||||
if !update {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Pack '{}' already exists in index. Use --update to replace it.",
|
||||
pack_ref
|
||||
));
|
||||
}
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!("Updating existing entry for '{}'", pack_ref));
|
||||
}
|
||||
} else {
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!("Adding new entry for '{}'", pack_ref));
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate checksum
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info("Calculating checksum...");
|
||||
}
|
||||
let checksum = calculate_directory_checksum(pack_dir)?;
|
||||
|
||||
// Build install sources
|
||||
let mut install_sources = Vec::new();
|
||||
|
||||
if let Some(ref git) = git_url {
|
||||
let default_ref = format!("v{}", version);
|
||||
let ref_value = git_ref.as_ref().map(|s| s.as_str()).unwrap_or(&default_ref);
|
||||
install_sources.push(serde_json::json!({
|
||||
"type": "git",
|
||||
"url": git,
|
||||
"ref": ref_value,
|
||||
"checksum": format!("sha256:{}", checksum)
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(ref archive) = archive_url {
|
||||
install_sources.push(serde_json::json!({
|
||||
"type": "archive",
|
||||
"url": archive,
|
||||
"checksum": format!("sha256:{}", checksum)
|
||||
}));
|
||||
}
|
||||
|
||||
// Extract other metadata
|
||||
let label = pack_yaml
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(pack_ref);
|
||||
|
||||
let description = pack_yaml
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let author = pack_yaml
|
||||
.get("author")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown");
|
||||
|
||||
let license = pack_yaml
|
||||
.get("license")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Apache-2.0");
|
||||
|
||||
let email = pack_yaml.get("email").and_then(|v| v.as_str());
|
||||
let homepage = pack_yaml.get("homepage").and_then(|v| v.as_str());
|
||||
let repository = pack_yaml.get("repository").and_then(|v| v.as_str());
|
||||
|
||||
let keywords: Vec<String> = pack_yaml
|
||||
.get("keywords")
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let runtime_deps: Vec<String> = pack_yaml
|
||||
.get("dependencies")
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Count components
|
||||
let actions_count = pack_yaml["actions"]
|
||||
.as_mapping()
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
let sensors_count = pack_yaml["sensors"]
|
||||
.as_mapping()
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
let triggers_count = pack_yaml["triggers"]
|
||||
.as_mapping()
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Build index entry
|
||||
let mut index_entry = serde_json::json!({
|
||||
"ref": pack_ref,
|
||||
"label": label,
|
||||
"description": description,
|
||||
"version": version,
|
||||
"author": author,
|
||||
"license": license,
|
||||
"keywords": keywords,
|
||||
"runtime_deps": runtime_deps,
|
||||
"install_sources": install_sources,
|
||||
"contents": {
|
||||
"actions": actions_count,
|
||||
"sensors": sensors_count,
|
||||
"triggers": triggers_count,
|
||||
"rules": 0,
|
||||
"workflows": 0
|
||||
}
|
||||
});
|
||||
|
||||
// Add optional fields
|
||||
if let Some(e) = email {
|
||||
index_entry["email"] = JsonValue::String(e.to_string());
|
||||
}
|
||||
if let Some(h) = homepage {
|
||||
index_entry["homepage"] = JsonValue::String(h.to_string());
|
||||
}
|
||||
if let Some(r) = repository {
|
||||
index_entry["repository"] = JsonValue::String(r.to_string());
|
||||
}
|
||||
|
||||
// Update or add entry
|
||||
if let Some(idx) = existing_index {
|
||||
packs[idx] = index_entry;
|
||||
} else {
|
||||
packs.push(index_entry);
|
||||
}
|
||||
|
||||
// Write updated index back to file
|
||||
let updated_content = serde_json::to_string_pretty(&index)?;
|
||||
fs::write(index_file_path, updated_content)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("✓ Index updated successfully: {}", index_path));
|
||||
output::print_info(&format!(" Pack: {} v{}", pack_ref, version));
|
||||
output::print_info(&format!(" Checksum: sha256:{}", checksum));
|
||||
}
|
||||
OutputFormat::Json => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"index_file": index_path,
|
||||
"pack_ref": pack_ref,
|
||||
"version": version,
|
||||
"checksum": format!("sha256:{}", checksum),
|
||||
"action": if existing_index.is_some() { "updated" } else { "added" }
|
||||
});
|
||||
output::print_output(&response, OutputFormat::Json)?;
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"index_file": index_path,
|
||||
"pack_ref": pack_ref,
|
||||
"version": version,
|
||||
"checksum": format!("sha256:{}", checksum),
|
||||
"action": if existing_index.is_some() { "updated" } else { "added" }
|
||||
});
|
||||
output::print_output(&response, OutputFormat::Yaml)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge multiple registry index files into one
|
||||
pub async fn handle_index_merge(
|
||||
output_path: String,
|
||||
input_paths: Vec<String>,
|
||||
force: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
// Check if output file exists
|
||||
let output_file_path = Path::new(&output_path);
|
||||
if output_file_path.exists() && !force {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Output file already exists: {}. Use --force to overwrite.",
|
||||
output_path
|
||||
));
|
||||
}
|
||||
|
||||
// Track all packs by ref (for deduplication)
|
||||
let mut packs_map: HashMap<String, JsonValue> = HashMap::new();
|
||||
let mut total_loaded = 0;
|
||||
let mut duplicates_resolved = 0;
|
||||
|
||||
// Load and merge all input files
|
||||
for input_path in &input_paths {
|
||||
let input_file_path = Path::new(input_path);
|
||||
if !input_file_path.exists() {
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_warning(&format!("Skipping missing file: {}", input_path));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!("Loading: {}", input_path));
|
||||
}
|
||||
|
||||
let index_content = fs::read_to_string(input_file_path)?;
|
||||
let index: JsonValue = serde_json::from_str(&index_content)?;
|
||||
|
||||
let packs = index
|
||||
.get("packs")
|
||||
.and_then(|p| p.as_array())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid index format in {}: missing 'packs' array",
|
||||
input_path
|
||||
)
|
||||
})?;
|
||||
|
||||
for pack in packs {
|
||||
let pack_ref = pack.get("ref").and_then(|r| r.as_str()).ok_or_else(|| {
|
||||
anyhow::anyhow!("Pack entry missing 'ref' field in {}", input_path)
|
||||
})?;
|
||||
|
||||
if packs_map.contains_key(pack_ref) {
|
||||
// Check versions and keep the latest
|
||||
let existing_version = packs_map[pack_ref]
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0");
|
||||
|
||||
let new_version = pack
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0");
|
||||
|
||||
// Simple string comparison (could use semver crate for proper comparison)
|
||||
if new_version > existing_version {
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!(
|
||||
" Updating '{}' from {} to {}",
|
||||
pack_ref, existing_version, new_version
|
||||
));
|
||||
}
|
||||
packs_map.insert(pack_ref.to_string(), pack.clone());
|
||||
} else {
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!(
|
||||
" Keeping '{}' at {} (newer than {})",
|
||||
pack_ref, existing_version, new_version
|
||||
));
|
||||
}
|
||||
}
|
||||
duplicates_resolved += 1;
|
||||
} else {
|
||||
packs_map.insert(pack_ref.to_string(), pack.clone());
|
||||
}
|
||||
total_loaded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Build merged index
|
||||
let packs: Vec<JsonValue> = packs_map.into_values().collect();
|
||||
let merged_index = serde_json::json!({
|
||||
"version": "1.0",
|
||||
"generated_at": chrono::Utc::now().to_rfc3339(),
|
||||
"packs": packs
|
||||
});
|
||||
|
||||
// Write merged index
|
||||
let merged_content = serde_json::to_string_pretty(&merged_index)?;
|
||||
fs::write(output_file_path, merged_content)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!(
|
||||
"✓ Merged {} index files into {}",
|
||||
input_paths.len(),
|
||||
output_path
|
||||
));
|
||||
output::print_info(&format!(" Total packs loaded: {}", total_loaded));
|
||||
output::print_info(&format!(" Unique packs: {}", packs.len()));
|
||||
if duplicates_resolved > 0 {
|
||||
output::print_info(&format!(" Duplicates resolved: {}", duplicates_resolved));
|
||||
}
|
||||
}
|
||||
OutputFormat::Json => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"output_file": output_path,
|
||||
"sources_count": input_paths.len(),
|
||||
"total_loaded": total_loaded,
|
||||
"unique_packs": packs.len(),
|
||||
"duplicates_resolved": duplicates_resolved
|
||||
});
|
||||
output::print_output(&response, OutputFormat::Json)?;
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"output_file": output_path,
|
||||
"sources_count": input_paths.len(),
|
||||
"total_loaded": total_loaded,
|
||||
"unique_packs": packs.len(),
|
||||
"duplicates_resolved": duplicates_resolved
|
||||
});
|
||||
output::print_output(&response, OutputFormat::Yaml)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
567
crates/cli/src/commands/rule.rs
Normal file
567
crates/cli/src/commands/rule.rs
Normal file
@@ -0,0 +1,567 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum RuleCommands {
|
||||
/// List all rules
|
||||
List {
|
||||
/// Filter by pack name
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
|
||||
/// Filter by enabled status
|
||||
#[arg(short, long)]
|
||||
enabled: Option<bool>,
|
||||
},
|
||||
/// Show details of a specific rule
|
||||
Show {
|
||||
/// Rule reference (pack.rule or ID)
|
||||
rule_ref: String,
|
||||
},
|
||||
/// Update a rule
|
||||
Update {
|
||||
/// Rule reference (pack.rule or ID)
|
||||
rule_ref: String,
|
||||
|
||||
/// Update label
|
||||
#[arg(long)]
|
||||
label: Option<String>,
|
||||
|
||||
/// Update description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
|
||||
/// Update conditions as JSON string
|
||||
#[arg(long)]
|
||||
conditions: Option<String>,
|
||||
|
||||
/// Update action parameters as JSON string
|
||||
#[arg(long)]
|
||||
action_params: Option<String>,
|
||||
|
||||
/// Update trigger parameters as JSON string
|
||||
#[arg(long)]
|
||||
trigger_params: Option<String>,
|
||||
|
||||
/// Update enabled status
|
||||
#[arg(long)]
|
||||
enabled: Option<bool>,
|
||||
},
|
||||
/// Enable a rule
|
||||
Enable {
|
||||
/// Rule reference (pack.rule or ID)
|
||||
rule_ref: String,
|
||||
},
|
||||
/// Disable a rule
|
||||
Disable {
|
||||
/// Rule reference (pack.rule or ID)
|
||||
rule_ref: String,
|
||||
},
|
||||
/// Create a new rule
|
||||
Create {
|
||||
/// Rule name
|
||||
#[arg(short, long)]
|
||||
name: String,
|
||||
|
||||
/// Pack ID or name
|
||||
#[arg(short, long)]
|
||||
pack: String,
|
||||
|
||||
/// Trigger reference
|
||||
#[arg(short, long)]
|
||||
trigger: String,
|
||||
|
||||
/// Action reference
|
||||
#[arg(short, long)]
|
||||
action: String,
|
||||
|
||||
/// Rule description
|
||||
#[arg(short, long)]
|
||||
description: Option<String>,
|
||||
|
||||
/// Rule criteria as JSON string
|
||||
#[arg(long)]
|
||||
criteria: Option<String>,
|
||||
|
||||
/// Enable the rule immediately
|
||||
#[arg(long)]
|
||||
enabled: bool,
|
||||
},
|
||||
/// Delete a rule
|
||||
Delete {
|
||||
/// Rule reference (pack.rule or ID)
|
||||
rule_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Rule {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
rule_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
trigger: Option<i64>,
|
||||
trigger_ref: String,
|
||||
#[serde(default)]
|
||||
action: Option<i64>,
|
||||
action_ref: String,
|
||||
enabled: bool,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct RuleDetail {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
rule_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
trigger: Option<i64>,
|
||||
trigger_ref: String,
|
||||
#[serde(default)]
|
||||
action: Option<i64>,
|
||||
action_ref: String,
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
conditions: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
action_params: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
trigger_params: Option<serde_json::Value>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateRuleRequest {
|
||||
name: String,
|
||||
pack_id: String,
|
||||
trigger_id: String,
|
||||
action_id: String,
|
||||
description: Option<String>,
|
||||
criteria: Option<serde_json::Value>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateRuleRequest {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
pub async fn handle_rule_command(
|
||||
profile: &Option<String>,
|
||||
command: RuleCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
RuleCommands::List { pack, enabled } => {
|
||||
handle_list(profile, pack, enabled, api_url, output_format).await
|
||||
}
|
||||
RuleCommands::Show { rule_ref } => {
|
||||
handle_show(profile, rule_ref, api_url, output_format).await
|
||||
}
|
||||
RuleCommands::Update {
|
||||
rule_ref,
|
||||
label,
|
||||
description,
|
||||
conditions,
|
||||
action_params,
|
||||
trigger_params,
|
||||
enabled,
|
||||
} => {
|
||||
handle_update(
|
||||
profile,
|
||||
rule_ref,
|
||||
label,
|
||||
description,
|
||||
conditions,
|
||||
action_params,
|
||||
trigger_params,
|
||||
enabled,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
RuleCommands::Enable { rule_ref } => {
|
||||
handle_toggle(profile, rule_ref, true, api_url, output_format).await
|
||||
}
|
||||
RuleCommands::Disable { rule_ref } => {
|
||||
handle_toggle(profile, rule_ref, false, api_url, output_format).await
|
||||
}
|
||||
RuleCommands::Create {
|
||||
name,
|
||||
pack,
|
||||
trigger,
|
||||
action,
|
||||
description,
|
||||
criteria,
|
||||
enabled,
|
||||
} => {
|
||||
handle_create(
|
||||
profile,
|
||||
name,
|
||||
pack,
|
||||
trigger,
|
||||
action,
|
||||
description,
|
||||
criteria,
|
||||
enabled,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
RuleCommands::Delete { rule_ref, yes } => {
|
||||
handle_delete(profile, rule_ref, yes, api_url, output_format).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
profile: &Option<String>,
|
||||
pack: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let mut query_params = Vec::new();
|
||||
if let Some(pack_name) = pack {
|
||||
query_params.push(format!("pack={}", pack_name));
|
||||
}
|
||||
if let Some(is_enabled) = enabled {
|
||||
query_params.push(format!("enabled={}", is_enabled));
|
||||
}
|
||||
|
||||
let path = if query_params.is_empty() {
|
||||
"/rules".to_string()
|
||||
} else {
|
||||
format!("/rules?{}", query_params.join("&"))
|
||||
};
|
||||
|
||||
let rules: Vec<Rule> = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&rules, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if rules.is_empty() {
|
||||
output::print_info("No rules found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Pack", "Name", "Trigger", "Action", "Enabled"],
|
||||
);
|
||||
|
||||
for rule in rules {
|
||||
table.add_row(vec![
|
||||
rule.id.to_string(),
|
||||
rule.pack_ref.clone(),
|
||||
rule.label.clone(),
|
||||
rule.trigger_ref.clone(),
|
||||
rule.action_ref.clone(),
|
||||
output::format_bool(rule.enabled),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show(
|
||||
profile: &Option<String>,
|
||||
rule_ref: String,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/rules/{}", rule_ref);
|
||||
let rule: RuleDetail = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&rule, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Rule: {}", rule.rule_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", rule.id.to_string()),
|
||||
("Ref", rule.rule_ref.clone()),
|
||||
("Pack", rule.pack_ref.clone()),
|
||||
("Label", rule.label.clone()),
|
||||
("Description", rule.description.clone()),
|
||||
("Trigger", rule.trigger_ref.clone()),
|
||||
("Action", rule.action_ref.clone()),
|
||||
("Enabled", output::format_bool(rule.enabled)),
|
||||
("Created", output::format_timestamp(&rule.created)),
|
||||
("Updated", output::format_timestamp(&rule.updated)),
|
||||
]);
|
||||
|
||||
if let Some(conditions) = rule.conditions {
|
||||
if !conditions.is_null() {
|
||||
output::print_section("Conditions");
|
||||
println!("{}", serde_json::to_string_pretty(&conditions)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(action_params) = rule.action_params {
|
||||
if !action_params.is_null() {
|
||||
output::print_section("Action Parameters");
|
||||
println!("{}", serde_json::to_string_pretty(&action_params)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(trigger_params) = rule.trigger_params {
|
||||
if !trigger_params.is_null() {
|
||||
output::print_section("Trigger Parameters");
|
||||
println!("{}", serde_json::to_string_pretty(&trigger_params)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_update(
|
||||
profile: &Option<String>,
|
||||
rule_ref: String,
|
||||
label: Option<String>,
|
||||
description: Option<String>,
|
||||
conditions: Option<String>,
|
||||
action_params: Option<String>,
|
||||
trigger_params: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Check that at least one field is provided
|
||||
if label.is_none()
|
||||
&& description.is_none()
|
||||
&& conditions.is_none()
|
||||
&& action_params.is_none()
|
||||
&& trigger_params.is_none()
|
||||
&& enabled.is_none()
|
||||
{
|
||||
anyhow::bail!("At least one field must be provided to update");
|
||||
}
|
||||
|
||||
// Parse JSON fields
|
||||
let conditions_json = if let Some(cond) = conditions {
|
||||
Some(serde_json::from_str(&cond)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let action_params_json = if let Some(params) = action_params {
|
||||
Some(serde_json::from_str(¶ms)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let trigger_params_json = if let Some(params) = trigger_params {
|
||||
Some(serde_json::from_str(¶ms)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UpdateRuleRequestCli {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
conditions: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
action_params: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
trigger_params: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
let request = UpdateRuleRequestCli {
|
||||
label,
|
||||
description,
|
||||
conditions: conditions_json,
|
||||
action_params: action_params_json,
|
||||
trigger_params: trigger_params_json,
|
||||
enabled,
|
||||
};
|
||||
|
||||
let path = format!("/rules/{}", rule_ref);
|
||||
let rule: RuleDetail = client.put(&path, &request).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&rule, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Rule '{}' updated successfully", rule.rule_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", rule.id.to_string()),
|
||||
("Ref", rule.rule_ref.clone()),
|
||||
("Pack", rule.pack_ref.clone()),
|
||||
("Label", rule.label.clone()),
|
||||
("Description", rule.description.clone()),
|
||||
("Trigger", rule.trigger_ref.clone()),
|
||||
("Action", rule.action_ref.clone()),
|
||||
("Enabled", output::format_bool(rule.enabled)),
|
||||
("Updated", output::format_timestamp(&rule.updated)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_toggle(
|
||||
profile: &Option<String>,
|
||||
rule_ref: String,
|
||||
enabled: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let request = UpdateRuleRequest { enabled };
|
||||
let path = format!("/rules/{}", rule_ref);
|
||||
let rule: Rule = client.patch(&path, &request).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&rule, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
let action = if enabled { "enabled" } else { "disabled" };
|
||||
output::print_success(&format!("Rule '{}' {}", rule.rule_ref, action));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_create(
|
||||
profile: &Option<String>,
|
||||
name: String,
|
||||
pack: String,
|
||||
trigger: String,
|
||||
action: String,
|
||||
description: Option<String>,
|
||||
criteria: Option<String>,
|
||||
enabled: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let criteria_value = if let Some(criteria_str) = criteria {
|
||||
Some(serde_json::from_str(&criteria_str)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = CreateRuleRequest {
|
||||
name: name.clone(),
|
||||
pack_id: pack,
|
||||
trigger_id: trigger,
|
||||
action_id: action,
|
||||
description,
|
||||
criteria: criteria_value,
|
||||
enabled,
|
||||
};
|
||||
|
||||
let rule: Rule = client.post("/rules", &request).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&rule, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Rule '{}' created successfully", rule.rule_ref));
|
||||
output::print_info(&format!("ID: {}", rule.id));
|
||||
output::print_info(&format!("Enabled: {}", rule.enabled));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete(
|
||||
profile: &Option<String>,
|
||||
rule_ref: String,
|
||||
yes: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Confirm deletion unless --yes is provided
|
||||
if !yes && matches!(output_format, OutputFormat::Table) {
|
||||
let confirm = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to delete rule '{}'?",
|
||||
rule_ref
|
||||
))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output::print_info("Deletion cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let path = format!("/rules/{}", rule_ref);
|
||||
client.delete_no_response(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let msg = serde_json::json!({"message": "Rule deleted successfully"});
|
||||
output::print_output(&msg, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Rule '{}' deleted successfully", rule_ref));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
187
crates/cli/src/commands/sensor.rs
Normal file
187
crates/cli/src/commands/sensor.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SensorCommands {
|
||||
/// List all sensors
|
||||
List {
|
||||
/// Filter by pack name
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
},
|
||||
/// Show details of a specific sensor
|
||||
Show {
|
||||
/// Sensor reference (pack.sensor or ID)
|
||||
sensor_ref: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Sensor {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
sensor_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
#[serde(default)]
|
||||
pack_ref: Option<String>,
|
||||
label: String,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
trigger_types: Vec<String>,
|
||||
enabled: bool,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SensorDetail {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
sensor_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
#[serde(default)]
|
||||
pack_ref: Option<String>,
|
||||
label: String,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
trigger_types: Vec<String>,
|
||||
#[serde(default)]
|
||||
entry_point: Option<String>,
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
poll_interval: Option<i32>,
|
||||
#[serde(default)]
|
||||
metadata: Option<serde_json::Value>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
pub async fn handle_sensor_command(
|
||||
profile: &Option<String>,
|
||||
command: SensorCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
SensorCommands::List { pack } => handle_list(pack, profile, api_url, output_format).await,
|
||||
SensorCommands::Show { sensor_ref } => {
|
||||
handle_show(sensor_ref, profile, api_url, output_format).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
pack: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = if let Some(pack_name) = pack {
|
||||
format!("/sensors?pack={}", pack_name)
|
||||
} else {
|
||||
"/sensors".to_string()
|
||||
};
|
||||
|
||||
let sensors: Vec<Sensor> = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&sensors, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if sensors.is_empty() {
|
||||
output::print_info("No sensors found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Pack", "Name", "Trigger", "Enabled", "Description"],
|
||||
);
|
||||
|
||||
for sensor in sensors {
|
||||
table.add_row(vec![
|
||||
sensor.id.to_string(),
|
||||
sensor.pack_ref.as_deref().unwrap_or("").to_string(),
|
||||
sensor.label.clone(),
|
||||
sensor.trigger_types.join(", "),
|
||||
output::format_bool(sensor.enabled),
|
||||
output::truncate(&sensor.description.unwrap_or_default(), 50),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show(
|
||||
sensor_ref: String,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/sensors/{}", sensor_ref);
|
||||
let sensor: SensorDetail = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&sensor, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Sensor: {}", sensor.sensor_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", sensor.id.to_string()),
|
||||
("Ref", sensor.sensor_ref.clone()),
|
||||
(
|
||||
"Pack",
|
||||
sensor.pack_ref.as_deref().unwrap_or("None").to_string(),
|
||||
),
|
||||
("Label", sensor.label.clone()),
|
||||
(
|
||||
"Description",
|
||||
sensor.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Trigger Types", sensor.trigger_types.join(", ")),
|
||||
(
|
||||
"Entry Point",
|
||||
sensor.entry_point.as_deref().unwrap_or("N/A").to_string(),
|
||||
),
|
||||
("Enabled", output::format_bool(sensor.enabled)),
|
||||
(
|
||||
"Poll Interval",
|
||||
sensor
|
||||
.poll_interval
|
||||
.map(|i| format!("{}s", i))
|
||||
.unwrap_or_else(|| "N/A".to_string()),
|
||||
),
|
||||
("Created", output::format_timestamp(&sensor.created)),
|
||||
("Updated", output::format_timestamp(&sensor.updated)),
|
||||
]);
|
||||
|
||||
if let Some(metadata) = sensor.metadata {
|
||||
if !metadata.is_null() {
|
||||
output::print_section("Metadata");
|
||||
println!("{}", serde_json::to_string_pretty(&metadata)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
346
crates/cli/src/commands/trigger.rs
Normal file
346
crates/cli/src/commands/trigger.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum TriggerCommands {
|
||||
/// List all triggers
|
||||
List {
|
||||
/// Filter by pack name
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
},
|
||||
/// Show details of a specific trigger
|
||||
Show {
|
||||
/// Trigger reference (pack.trigger or ID)
|
||||
trigger_ref: String,
|
||||
},
|
||||
/// Update a trigger
|
||||
Update {
|
||||
/// Trigger reference (pack.trigger or ID)
|
||||
trigger_ref: String,
|
||||
|
||||
/// Update label
|
||||
#[arg(long)]
|
||||
label: Option<String>,
|
||||
|
||||
/// Update description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
|
||||
/// Update enabled status
|
||||
#[arg(long)]
|
||||
enabled: Option<bool>,
|
||||
},
|
||||
/// Delete a trigger
|
||||
Delete {
|
||||
/// Trigger reference (pack.trigger or ID)
|
||||
trigger_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short, long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Trigger {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
trigger_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
#[serde(default)]
|
||||
pack_ref: Option<String>,
|
||||
label: String,
|
||||
description: Option<String>,
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
param_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
out_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
webhook_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
webhook_key: Option<String>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TriggerDetail {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
trigger_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
#[serde(default)]
|
||||
pack_ref: Option<String>,
|
||||
label: String,
|
||||
description: Option<String>,
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
param_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
out_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
webhook_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
webhook_key: Option<String>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
pub async fn handle_trigger_command(
|
||||
profile: &Option<String>,
|
||||
command: TriggerCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
TriggerCommands::List { pack } => handle_list(pack, profile, api_url, output_format).await,
|
||||
TriggerCommands::Show { trigger_ref } => {
|
||||
handle_show(trigger_ref, profile, api_url, output_format).await
|
||||
}
|
||||
TriggerCommands::Update {
|
||||
trigger_ref,
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
} => {
|
||||
handle_update(
|
||||
trigger_ref,
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
profile,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
TriggerCommands::Delete { trigger_ref, yes } => {
|
||||
handle_delete(trigger_ref, yes, profile, api_url, output_format).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
pack: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = if let Some(pack_name) = pack {
|
||||
format!("/triggers?pack={}", pack_name)
|
||||
} else {
|
||||
"/triggers".to_string()
|
||||
};
|
||||
|
||||
let triggers: Vec<Trigger> = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&triggers, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if triggers.is_empty() {
|
||||
output::print_info("No triggers found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(&mut table, vec!["ID", "Pack", "Name", "Description"]);
|
||||
|
||||
for trigger in triggers {
|
||||
table.add_row(vec![
|
||||
trigger.id.to_string(),
|
||||
trigger.pack_ref.as_deref().unwrap_or("").to_string(),
|
||||
trigger.label.clone(),
|
||||
output::truncate(&trigger.description.unwrap_or_default(), 50),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show(
|
||||
trigger_ref: String,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/triggers/{}", trigger_ref);
|
||||
let trigger: TriggerDetail = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&trigger, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Trigger: {}", trigger.trigger_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", trigger.id.to_string()),
|
||||
("Ref", trigger.trigger_ref.clone()),
|
||||
(
|
||||
"Pack",
|
||||
trigger.pack_ref.as_deref().unwrap_or("None").to_string(),
|
||||
),
|
||||
("Label", trigger.label.clone()),
|
||||
(
|
||||
"Description",
|
||||
trigger.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Enabled", output::format_bool(trigger.enabled)),
|
||||
(
|
||||
"Webhook Enabled",
|
||||
output::format_bool(trigger.webhook_enabled.unwrap_or(false)),
|
||||
),
|
||||
("Created", output::format_timestamp(&trigger.created)),
|
||||
("Updated", output::format_timestamp(&trigger.updated)),
|
||||
]);
|
||||
|
||||
if let Some(webhook_key) = &trigger.webhook_key {
|
||||
output::print_section("Webhook");
|
||||
output::print_info(&format!("Key: {}", webhook_key));
|
||||
}
|
||||
|
||||
if let Some(param_schema) = &trigger.param_schema {
|
||||
if !param_schema.is_null() {
|
||||
output::print_section("Parameter Schema");
|
||||
println!("{}", serde_json::to_string_pretty(param_schema)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(out_schema) = &trigger.out_schema {
|
||||
if !out_schema.is_null() {
|
||||
output::print_section("Output Schema");
|
||||
println!("{}", serde_json::to_string_pretty(out_schema)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_update(
|
||||
trigger_ref: String,
|
||||
label: Option<String>,
|
||||
description: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Check that at least one field is provided
|
||||
if label.is_none() && description.is_none() && enabled.is_none() {
|
||||
anyhow::bail!("At least one field must be provided to update");
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UpdateTriggerRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
let request = UpdateTriggerRequest {
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
};
|
||||
|
||||
let path = format!("/triggers/{}", trigger_ref);
|
||||
let trigger: TriggerDetail = client.put(&path, &request).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&trigger, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!(
|
||||
"Trigger '{}' updated successfully",
|
||||
trigger.trigger_ref
|
||||
));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", trigger.id.to_string()),
|
||||
("Ref", trigger.trigger_ref.clone()),
|
||||
(
|
||||
"Pack",
|
||||
trigger.pack_ref.as_deref().unwrap_or("None").to_string(),
|
||||
),
|
||||
("Label", trigger.label.clone()),
|
||||
(
|
||||
"Description",
|
||||
trigger.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Enabled", output::format_bool(trigger.enabled)),
|
||||
("Updated", output::format_timestamp(&trigger.updated)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete(
|
||||
trigger_ref: String,
|
||||
yes: bool,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Confirm deletion unless --yes is provided
|
||||
if !yes && matches!(output_format, OutputFormat::Table) {
|
||||
let confirm = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to delete trigger '{}'?",
|
||||
trigger_ref
|
||||
))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output::print_info("Delete cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let path = format!("/triggers/{}", trigger_ref);
|
||||
client.delete_no_response(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let msg = serde_json::json!({"message": "Trigger deleted successfully"});
|
||||
output::print_output(&msg, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Trigger '{}' deleted successfully", trigger_ref));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
459
crates/cli/src/config.rs
Normal file
459
crates/cli/src/config.rs
Normal file
@@ -0,0 +1,459 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// CLI configuration stored in user's home directory
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CliConfig {
|
||||
/// Current active profile name
|
||||
#[serde(default = "default_profile_name")]
|
||||
pub current_profile: String,
|
||||
/// Named profiles (like SSH hosts)
|
||||
#[serde(default)]
|
||||
pub profiles: HashMap<String, Profile>,
|
||||
/// Default output format (can be overridden per-profile)
|
||||
#[serde(default = "default_output_format")]
|
||||
pub default_output_format: String,
|
||||
}
|
||||
|
||||
fn default_profile_name() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
fn default_output_format() -> String {
|
||||
"table".to_string()
|
||||
}
|
||||
|
||||
/// A named profile for connecting to an Attune server
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
/// API endpoint URL
|
||||
pub api_url: String,
|
||||
/// Authentication token
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_token: Option<String>,
|
||||
/// Refresh token
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
/// Output format override for this profile
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_format: Option<String>,
|
||||
/// Optional description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CliConfig {
|
||||
fn default() -> Self {
|
||||
let mut profiles = HashMap::new();
|
||||
profiles.insert(
|
||||
"default".to_string(),
|
||||
Profile {
|
||||
api_url: "http://localhost:8080".to_string(),
|
||||
auth_token: None,
|
||||
refresh_token: None,
|
||||
output_format: None,
|
||||
description: Some("Default local server".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
current_profile: "default".to_string(),
|
||||
profiles,
|
||||
default_output_format: default_output_format(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CliConfig {
|
||||
/// Get the configuration file path
|
||||
pub fn config_path() -> Result<PathBuf> {
|
||||
// Respect XDG_CONFIG_HOME environment variable (for tests and user overrides)
|
||||
let config_dir = if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
|
||||
PathBuf::from(xdg_config)
|
||||
} else {
|
||||
dirs::config_dir().context("Failed to determine config directory")?
|
||||
};
|
||||
|
||||
let attune_config_dir = config_dir.join("attune");
|
||||
fs::create_dir_all(&attune_config_dir).context("Failed to create config directory")?;
|
||||
|
||||
Ok(attune_config_dir.join("config.yaml"))
|
||||
}
|
||||
|
||||
/// Load configuration from file, or create default if not exists
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::config_path()?;
|
||||
|
||||
if !path.exists() {
|
||||
let config = Self::default();
|
||||
config.save()?;
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path).context("Failed to read config file")?;
|
||||
|
||||
let config: Self =
|
||||
serde_yaml_ng::from_str(&content).context("Failed to parse config file")?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Save configuration to file
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::config_path()?;
|
||||
|
||||
let content = serde_yaml_ng::to_string(self).context("Failed to serialize config")?;
|
||||
|
||||
fs::write(&path, content).context("Failed to write config file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current active profile
|
||||
pub fn current_profile(&self) -> Result<&Profile> {
|
||||
self.profiles
|
||||
.get(&self.current_profile)
|
||||
.context(format!("Profile '{}' not found", self.current_profile))
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the current profile
|
||||
pub fn current_profile_mut(&mut self) -> Result<&mut Profile> {
|
||||
let profile_name = self.current_profile.clone();
|
||||
self.profiles
|
||||
.get_mut(&profile_name)
|
||||
.context(format!("Profile '{}' not found", profile_name))
|
||||
}
|
||||
|
||||
/// Get a profile by name
|
||||
pub fn get_profile(&self, name: &str) -> Option<&Profile> {
|
||||
self.profiles.get(name)
|
||||
}
|
||||
|
||||
/// Switch to a different profile
|
||||
pub fn switch_profile(&mut self, name: String) -> Result<()> {
|
||||
if !self.profiles.contains_key(&name) {
|
||||
anyhow::bail!("Profile '{}' does not exist", name);
|
||||
}
|
||||
self.current_profile = name;
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Add or update a profile
|
||||
pub fn set_profile(&mut self, name: String, profile: Profile) -> Result<()> {
|
||||
self.profiles.insert(name, profile);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Remove a profile
|
||||
pub fn remove_profile(&mut self, name: &str) -> Result<()> {
|
||||
if self.current_profile == name {
|
||||
anyhow::bail!("Cannot remove active profile");
|
||||
}
|
||||
if name == "default" {
|
||||
anyhow::bail!("Cannot remove the default profile");
|
||||
}
|
||||
self.profiles.remove(name);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// List all profile names
|
||||
pub fn list_profiles(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = self.profiles.keys().cloned().collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Set the API URL for the current profile
|
||||
///
|
||||
/// Part of configuration management API - used by `attune config set api-url` command
|
||||
#[allow(dead_code)]
|
||||
pub fn set_api_url(&mut self, url: String) -> Result<()> {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.api_url = url;
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Set authentication tokens for the current profile
|
||||
pub fn set_auth(&mut self, access_token: String, refresh_token: String) -> Result<()> {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.auth_token = Some(access_token);
|
||||
profile.refresh_token = Some(refresh_token);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Clear authentication tokens for the current profile
|
||||
pub fn clear_auth(&mut self) -> Result<()> {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.auth_token = None;
|
||||
profile.refresh_token = None;
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Set a configuration value by key
|
||||
pub fn set_value(&mut self, key: &str, value: String) -> Result<()> {
|
||||
match key {
|
||||
"api_url" => {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.api_url = value;
|
||||
}
|
||||
"output_format" => {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.output_format = Some(value);
|
||||
}
|
||||
"default_output_format" => {
|
||||
self.default_output_format = value;
|
||||
}
|
||||
"current_profile" => {
|
||||
self.switch_profile(value)?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => anyhow::bail!("Unknown config key: {}", key),
|
||||
}
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Get a configuration value by key
|
||||
pub fn get_value(&self, key: &str) -> Result<String> {
|
||||
match key {
|
||||
"api_url" => {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile.api_url.clone())
|
||||
}
|
||||
"output_format" => {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile
|
||||
.output_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.default_output_format.clone()))
|
||||
}
|
||||
"default_output_format" => Ok(self.default_output_format.clone()),
|
||||
"current_profile" => Ok(self.current_profile.clone()),
|
||||
"auth_token" => {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile
|
||||
.auth_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string())
|
||||
}
|
||||
"refresh_token" => {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string())
|
||||
}
|
||||
_ => anyhow::bail!("Unknown config key: {}", key),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all configuration keys and values for current profile
|
||||
pub fn list_all(&self) -> Vec<(String, String)> {
|
||||
let profile = match self.current_profile() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
vec![
|
||||
("current_profile".to_string(), self.current_profile.clone()),
|
||||
("api_url".to_string(), profile.api_url.clone()),
|
||||
(
|
||||
"output_format".to_string(),
|
||||
profile
|
||||
.output_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.default_output_format.clone()),
|
||||
),
|
||||
(
|
||||
"default_output_format".to_string(),
|
||||
self.default_output_format.clone(),
|
||||
),
|
||||
(
|
||||
"auth_token".to_string(),
|
||||
profile
|
||||
.auth_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
"refresh_token".to_string(),
|
||||
profile
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Load configuration with optional profile override (without saving)
|
||||
///
|
||||
/// Used by `--profile` flag to temporarily use a different profile
|
||||
pub fn load_with_profile(profile_name: Option<&str>) -> Result<Self> {
|
||||
let mut config = Self::load()?;
|
||||
|
||||
if let Some(name) = profile_name {
|
||||
// Temporarily switch profile without saving
|
||||
if !config.profiles.contains_key(name) {
|
||||
anyhow::bail!("Profile '{}' does not exist", name);
|
||||
}
|
||||
config.current_profile = name.to_string();
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get the effective API URL (from override, current profile, or default)
|
||||
pub fn effective_api_url(&self, override_url: &Option<String>) -> String {
|
||||
if let Some(url) = override_url {
|
||||
return url.clone();
|
||||
}
|
||||
|
||||
if let Ok(profile) = self.current_profile() {
|
||||
profile.api_url.clone()
|
||||
} else {
|
||||
"http://localhost:8080".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get API URL for current profile (without override)
|
||||
#[allow(unused)]
|
||||
pub fn api_url(&self) -> Result<String> {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile.api_url.clone())
|
||||
}
|
||||
|
||||
/// Get auth token for current profile
|
||||
pub fn auth_token(&self) -> Result<Option<String>> {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile.auth_token.clone())
|
||||
}
|
||||
|
||||
/// Get refresh token for current profile
|
||||
pub fn refresh_token(&self) -> Result<Option<String>> {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile.refresh_token.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = CliConfig::default();
|
||||
assert_eq!(config.current_profile, "default");
|
||||
assert_eq!(config.default_output_format, "table");
|
||||
assert!(config.profiles.contains_key("default"));
|
||||
|
||||
let profile = config.current_profile().unwrap();
|
||||
assert_eq!(profile.api_url, "http://localhost:8080");
|
||||
assert!(profile.auth_token.is_none());
|
||||
assert!(profile.refresh_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_api_url() {
|
||||
let config = CliConfig::default();
|
||||
|
||||
// No override
|
||||
assert_eq!(config.effective_api_url(&None), "http://localhost:8080");
|
||||
|
||||
// With override
|
||||
let override_url = Some("http://example.com".to_string());
|
||||
assert_eq!(
|
||||
config.effective_api_url(&override_url),
|
||||
"http://example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_management() {
|
||||
let mut config = CliConfig::default();
|
||||
|
||||
// Add a new profile
|
||||
let staging_profile = Profile {
|
||||
api_url: "https://staging.example.com".to_string(),
|
||||
auth_token: None,
|
||||
refresh_token: None,
|
||||
output_format: Some("json".to_string()),
|
||||
description: Some("Staging environment".to_string()),
|
||||
};
|
||||
config
|
||||
.set_profile("staging".to_string(), staging_profile)
|
||||
.unwrap();
|
||||
|
||||
// List profiles
|
||||
let profiles = config.list_profiles();
|
||||
assert!(profiles.contains(&"default".to_string()));
|
||||
assert!(profiles.contains(&"staging".to_string()));
|
||||
|
||||
// Switch to staging
|
||||
config.switch_profile("staging".to_string()).unwrap();
|
||||
assert_eq!(config.current_profile, "staging");
|
||||
|
||||
let profile = config.current_profile().unwrap();
|
||||
assert_eq!(profile.api_url, "https://staging.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cannot_remove_default_profile() {
|
||||
let mut config = CliConfig::default();
|
||||
let result = config.remove_profile("default");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cannot_remove_active_profile() {
|
||||
let mut config = CliConfig::default();
|
||||
|
||||
let test_profile = Profile {
|
||||
api_url: "http://test.com".to_string(),
|
||||
auth_token: None,
|
||||
refresh_token: None,
|
||||
output_format: None,
|
||||
description: None,
|
||||
};
|
||||
config
|
||||
.set_profile("test".to_string(), test_profile)
|
||||
.unwrap();
|
||||
config.switch_profile("test".to_string()).unwrap();
|
||||
|
||||
let result = config.remove_profile("test");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_set_value() {
|
||||
let mut config = CliConfig::default();
|
||||
|
||||
assert_eq!(
|
||||
config.get_value("api_url").unwrap(),
|
||||
"http://localhost:8080"
|
||||
);
|
||||
assert_eq!(config.get_value("output_format").unwrap(), "table");
|
||||
|
||||
// Set API URL for current profile
|
||||
config
|
||||
.set_value("api_url", "http://test.com".to_string())
|
||||
.unwrap();
|
||||
assert_eq!(config.get_value("api_url").unwrap(), "http://test.com");
|
||||
|
||||
// Set output format for current profile
|
||||
config
|
||||
.set_value("output_format", "json".to_string())
|
||||
.unwrap();
|
||||
assert_eq!(config.get_value("output_format").unwrap(), "json");
|
||||
}
|
||||
}
|
||||
218
crates/cli/src/main.rs
Normal file
218
crates/cli/src/main.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::process;
|
||||
|
||||
mod client;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod output;
|
||||
|
||||
use commands::{
|
||||
action::{handle_action_command, ActionCommands},
|
||||
auth::AuthCommands,
|
||||
config::ConfigCommands,
|
||||
execution::ExecutionCommands,
|
||||
pack::PackCommands,
|
||||
rule::RuleCommands,
|
||||
sensor::SensorCommands,
|
||||
trigger::TriggerCommands,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "attune")]
|
||||
#[command(author, version, about = "Attune CLI - Event-driven automation platform", long_about = None)]
|
||||
#[command(propagate_version = true)]
|
||||
struct Cli {
|
||||
/// Profile to use (overrides config)
|
||||
#[arg(short = 'p', long, env = "ATTUNE_PROFILE", global = true)]
|
||||
profile: Option<String>,
|
||||
|
||||
/// API endpoint URL (overrides config)
|
||||
#[arg(long, env = "ATTUNE_API_URL", global = true)]
|
||||
api_url: Option<String>,
|
||||
|
||||
/// Output format
|
||||
#[arg(long, value_enum, default_value = "table", global = true, conflicts_with_all = ["json", "yaml"])]
|
||||
output: output::OutputFormat,
|
||||
|
||||
/// Output as JSON (shorthand for --output json)
|
||||
#[arg(short = 'j', long, global = true, conflicts_with_all = ["output", "yaml"])]
|
||||
json: bool,
|
||||
|
||||
/// Output as YAML (shorthand for --output yaml)
|
||||
#[arg(short = 'y', long, global = true, conflicts_with_all = ["output", "json"])]
|
||||
yaml: bool,
|
||||
|
||||
/// Verbose logging
|
||||
#[arg(short, long, global = true)]
|
||||
verbose: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Authentication commands
|
||||
Auth {
|
||||
#[command(subcommand)]
|
||||
command: AuthCommands,
|
||||
},
|
||||
/// Pack management
|
||||
Pack {
|
||||
#[command(subcommand)]
|
||||
command: PackCommands,
|
||||
},
|
||||
/// Action management and execution
|
||||
Action {
|
||||
#[command(subcommand)]
|
||||
command: ActionCommands,
|
||||
},
|
||||
/// Rule management
|
||||
Rule {
|
||||
#[command(subcommand)]
|
||||
command: RuleCommands,
|
||||
},
|
||||
/// Execution monitoring
|
||||
Execution {
|
||||
#[command(subcommand)]
|
||||
command: ExecutionCommands,
|
||||
},
|
||||
/// Trigger management
|
||||
Trigger {
|
||||
#[command(subcommand)]
|
||||
command: TriggerCommands,
|
||||
},
|
||||
/// Sensor management
|
||||
Sensor {
|
||||
#[command(subcommand)]
|
||||
command: SensorCommands,
|
||||
},
|
||||
/// Configuration management
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
command: ConfigCommands,
|
||||
},
|
||||
/// Run an action (shortcut for 'action execute')
|
||||
Run {
|
||||
/// Action reference (pack.action)
|
||||
action_ref: String,
|
||||
|
||||
/// Action parameters in key=value format
|
||||
#[arg(long)]
|
||||
param: Vec<String>,
|
||||
|
||||
/// Parameters as JSON string
|
||||
#[arg(long, conflicts_with = "param")]
|
||||
params_json: Option<String>,
|
||||
|
||||
/// Wait for execution to complete
|
||||
#[arg(short, long)]
|
||||
wait: bool,
|
||||
|
||||
/// Timeout in seconds when waiting (default: 300)
|
||||
#[arg(long, default_value = "300", requires = "wait")]
|
||||
timeout: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Initialize logging
|
||||
if cli.verbose {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
}
|
||||
|
||||
// Determine output format from flags
|
||||
let output_format = if cli.json {
|
||||
output::OutputFormat::Json
|
||||
} else if cli.yaml {
|
||||
output::OutputFormat::Yaml
|
||||
} else {
|
||||
cli.output
|
||||
};
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Auth { command } => {
|
||||
commands::auth::handle_auth_command(&cli.profile, command, &cli.api_url, output_format)
|
||||
.await
|
||||
}
|
||||
Commands::Pack { command } => {
|
||||
commands::pack::handle_pack_command(&cli.profile, command, &cli.api_url, output_format)
|
||||
.await
|
||||
}
|
||||
Commands::Action { command } => {
|
||||
commands::action::handle_action_command(
|
||||
&cli.profile,
|
||||
command,
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Rule { command } => {
|
||||
commands::rule::handle_rule_command(&cli.profile, command, &cli.api_url, output_format)
|
||||
.await
|
||||
}
|
||||
Commands::Execution { command } => {
|
||||
commands::execution::handle_execution_command(
|
||||
&cli.profile,
|
||||
command,
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Trigger { command } => {
|
||||
commands::trigger::handle_trigger_command(
|
||||
&cli.profile,
|
||||
command,
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Sensor { command } => {
|
||||
commands::sensor::handle_sensor_command(
|
||||
&cli.profile,
|
||||
command,
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Config { command } => {
|
||||
commands::config::handle_config_command(&cli.profile, command, output_format).await
|
||||
}
|
||||
Commands::Run {
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
wait,
|
||||
timeout,
|
||||
} => {
|
||||
// Delegate to action execute command
|
||||
handle_action_command(
|
||||
&cli.profile,
|
||||
ActionCommands::Execute {
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
wait,
|
||||
timeout,
|
||||
},
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
167
crates/cli/src/output.rs
Normal file
167
crates/cli/src/output.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use anyhow::Result;
|
||||
use clap::ValueEnum;
|
||||
use colored::Colorize;
|
||||
use comfy_table::{modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, Cell, Color, Table};
|
||||
use serde::Serialize;
|
||||
use std::fmt::Display;
|
||||
|
||||
/// Output format for CLI commands
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq)]
|
||||
pub enum OutputFormat {
|
||||
/// Human-readable table format
|
||||
Table,
|
||||
/// JSON format for scripting
|
||||
Json,
|
||||
/// YAML format
|
||||
Yaml,
|
||||
}
|
||||
|
||||
impl Display for OutputFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
OutputFormat::Table => write!(f, "table"),
|
||||
OutputFormat::Json => write!(f, "json"),
|
||||
OutputFormat::Yaml => write!(f, "yaml"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Print output in the specified format
|
||||
pub fn print_output<T: Serialize>(data: &T, format: OutputFormat) -> Result<()> {
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
let json = serde_json::to_string_pretty(data)?;
|
||||
println!("{}", json);
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
let yaml = serde_yaml_ng::to_string(data)?;
|
||||
println!("{}", yaml);
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
// For table format, the caller should use specific table functions
|
||||
let json = serde_json::to_string_pretty(data)?;
|
||||
println!("{}", json);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print a success message
|
||||
pub fn print_success(message: &str) {
|
||||
println!("{} {}", "✓".green().bold(), message);
|
||||
}
|
||||
|
||||
/// Print an info message
|
||||
pub fn print_info(message: &str) {
|
||||
println!("{} {}", "ℹ".blue().bold(), message);
|
||||
}
|
||||
|
||||
/// Print a warning message
|
||||
pub fn print_warning(message: &str) {
|
||||
eprintln!("{} {}", "⚠".yellow().bold(), message);
|
||||
}
|
||||
|
||||
/// Print an error message
|
||||
pub fn print_error(message: &str) {
|
||||
eprintln!("{} {}", "✗".red().bold(), message);
|
||||
}
|
||||
|
||||
/// Create a new table with default styling
|
||||
pub fn create_table() -> Table {
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.apply_modifier(UTF8_ROUND_CORNERS);
|
||||
table
|
||||
}
|
||||
|
||||
/// Add a header row to a table with styling
|
||||
pub fn add_header(table: &mut Table, headers: Vec<&str>) {
|
||||
let cells: Vec<Cell> = headers
|
||||
.into_iter()
|
||||
.map(|h| Cell::new(h).fg(Color::Cyan))
|
||||
.collect();
|
||||
table.set_header(cells);
|
||||
}
|
||||
|
||||
/// Print a table of key-value pairs
|
||||
pub fn print_key_value_table(pairs: Vec<(&str, String)>) {
|
||||
let mut table = create_table();
|
||||
add_header(&mut table, vec!["Key", "Value"]);
|
||||
|
||||
for (key, value) in pairs {
|
||||
table.add_row(vec![Cell::new(key).fg(Color::Yellow), Cell::new(value)]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
|
||||
/// Print a simple list
|
||||
pub fn print_list(items: Vec<String>) {
|
||||
for item in items {
|
||||
println!(" • {}", item);
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a titled section
|
||||
pub fn print_section(title: &str) {
|
||||
println!("\n{}", title.bold().underline());
|
||||
}
|
||||
|
||||
/// Format a boolean as a colored checkmark or cross
|
||||
pub fn format_bool(value: bool) -> String {
|
||||
if value {
|
||||
"✓".green().to_string()
|
||||
} else {
|
||||
"✗".red().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a status with color
|
||||
pub fn format_status(status: &str) -> String {
|
||||
match status.to_lowercase().as_str() {
|
||||
"succeeded" | "success" | "enabled" | "active" | "running" => status.green().to_string(),
|
||||
"failed" | "error" | "disabled" | "inactive" => status.red().to_string(),
|
||||
"pending" | "scheduled" | "queued" => status.yellow().to_string(),
|
||||
"canceled" | "cancelled" => status.bright_black().to_string(),
|
||||
_ => status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to a maximum length with ellipsis
|
||||
pub fn truncate(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max_len.saturating_sub(3)])
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a timestamp in a human-readable way
|
||||
pub fn format_timestamp(timestamp: &str) -> String {
|
||||
// Try to parse and format nicely, otherwise return as-is
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
} else {
|
||||
timestamp.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_truncate() {
|
||||
assert_eq!(truncate("short", 10), "short");
|
||||
assert_eq!(truncate("this is a long string", 10), "this is...");
|
||||
assert_eq!(truncate("exactly10!", 10), "exactly10!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_display() {
|
||||
assert_eq!(OutputFormat::Table.to_string(), "table");
|
||||
assert_eq!(OutputFormat::Json.to_string(), "json");
|
||||
assert_eq!(OutputFormat::Yaml.to_string(), "yaml");
|
||||
}
|
||||
}
|
||||
94
crates/cli/tests/KNOWN_ISSUES.md
Normal file
94
crates/cli/tests/KNOWN_ISSUES.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Known Issues with CLI Integration Tests
|
||||
|
||||
## Test Assertion Mismatches
|
||||
|
||||
The integration tests are currently failing due to mismatches between expected output strings and actual CLI output. The CLI uses colored output with Unicode symbols (checkmarks, etc.) that need to be matched in test assertions.
|
||||
|
||||
### Status
|
||||
|
||||
- **Tests Written**: ✅ 60+ comprehensive integration tests
|
||||
- **Test Infrastructure**: ✅ Mock server, fixtures, utilities all working
|
||||
- **CLI Compilation**: ✅ No compilation errors
|
||||
- **Issue**: Test assertions need to match actual CLI output format
|
||||
|
||||
### Specific Issues
|
||||
|
||||
#### 1. Authentication Commands
|
||||
- Tests expect: "Successfully authenticated", "Logged out"
|
||||
- Actual output may include: "✓ Successfully authenticated", "✓ Successfully logged out"
|
||||
- **Solution**: Update predicates to match actual output or strip formatting
|
||||
|
||||
#### 2. Output Format
|
||||
- CLI uses colored output with symbols
|
||||
- Tests may need to account for ANSI color codes
|
||||
- **Solution**: Either disable colors in tests or strip them in assertions
|
||||
|
||||
#### 3. Success Messages
|
||||
- Different commands may use different success message formats
|
||||
- Need to verify actual output for each command
|
||||
- **Solution**: Run CLI manually to capture actual output, update test expectations
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Run Single Test with Debug Output**:
|
||||
```bash
|
||||
cargo test --package attune-cli --test test_auth test_logout -- --nocapture
|
||||
```
|
||||
|
||||
2. **Capture Actual CLI Output**:
|
||||
```bash
|
||||
# Run CLI commands manually to see exact output
|
||||
attune auth logout
|
||||
attune auth login --username test --password test
|
||||
```
|
||||
|
||||
3. **Update Test Assertions**:
|
||||
- Replace exact string matches with flexible predicates
|
||||
- Use `.or()` to match multiple possible outputs
|
||||
- Consider case-insensitive matching where appropriate
|
||||
- Strip ANSI color codes if needed
|
||||
|
||||
4. **Consider Test Helpers**:
|
||||
- Add helper function to normalize CLI output (strip colors, symbols)
|
||||
- Create custom predicates for common output patterns
|
||||
- Add constants for expected output strings
|
||||
|
||||
### Workaround
|
||||
|
||||
To temporarily disable colored output in tests, the CLI could check for an environment variable:
|
||||
|
||||
```rust
|
||||
// In CLI code
|
||||
if env::var("NO_COLOR").is_ok() || env::var("ATTUNE_TEST_MODE").is_ok() {
|
||||
// Disable colored output
|
||||
}
|
||||
```
|
||||
|
||||
Then in tests:
|
||||
```rust
|
||||
cmd.env("ATTUNE_TEST_MODE", "1")
|
||||
```
|
||||
|
||||
### Impact
|
||||
|
||||
- **Severity**: Low - Tests are structurally correct, just need assertion updates
|
||||
- **Blocking**: No - CLI functionality is working correctly
|
||||
- **Effort**: Small - Just need to update string matches in assertions
|
||||
|
||||
### Files Affected
|
||||
|
||||
- `tests/test_auth.rs` - Authentication test assertions
|
||||
- `tests/test_packs.rs` - Pack command test assertions
|
||||
- `tests/test_actions.rs` - Action command test assertions
|
||||
- `tests/test_executions.rs` - Execution command test assertions
|
||||
- `tests/test_config.rs` - Config command test assertions
|
||||
- `tests/test_rules_triggers_sensors.rs` - Rules/triggers/sensors test assertions
|
||||
|
||||
### Recommendation
|
||||
|
||||
1. Add a test helper module with output normalization
|
||||
2. Update all test assertions to use flexible matching
|
||||
3. Consider adding a `--plain` or `--no-color` flag to CLI for testing
|
||||
4. Document expected output format for each command
|
||||
|
||||
This is a minor polish issue that doesn't block CLI functionality or prevent the test suite from being valuable once assertions are corrected.
|
||||
290
crates/cli/tests/README.md
Normal file
290
crates/cli/tests/README.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Attune CLI Integration Tests
|
||||
|
||||
This directory contains comprehensive integration tests for the Attune CLI tool. These tests verify that the CLI correctly interacts with the Attune API server by mocking API responses and testing real CLI command execution.
|
||||
|
||||
## Overview
|
||||
|
||||
The integration tests are organized into several test files:
|
||||
|
||||
- **`test_auth.rs`** - Authentication commands (login, logout, whoami)
|
||||
- **`test_packs.rs`** - Pack management commands (list, get)
|
||||
- **`test_actions.rs`** - Action commands (list, get, execute)
|
||||
- **`test_executions.rs`** - Execution monitoring (list, get, result filtering)
|
||||
- **`test_config.rs`** - Configuration and profile management
|
||||
- **`test_rules_triggers_sensors.rs`** - Rules, triggers, and sensors commands
|
||||
- **`common/mod.rs`** - Shared test utilities and mock fixtures
|
||||
|
||||
## Test Architecture
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
The tests use `TestFixture` from the `common` module, which provides:
|
||||
|
||||
- **Mock API Server**: Uses `wiremock` to simulate the Attune API
|
||||
- **Temporary Config**: Creates isolated config directories for each test
|
||||
- **Helper Functions**: Pre-configured mock responses for common API endpoints
|
||||
|
||||
### Test Strategy
|
||||
|
||||
Each test:
|
||||
|
||||
1. Creates a fresh test fixture with an isolated config directory
|
||||
2. Writes a test configuration (with or without authentication tokens)
|
||||
3. Mounts mock API responses on the mock server
|
||||
4. Executes the CLI binary with specific arguments
|
||||
5. Asserts on exit status, stdout, and stderr content
|
||||
6. Verifies config file changes (if applicable)
|
||||
|
||||
## Running the Tests
|
||||
|
||||
### Run All Integration Tests
|
||||
|
||||
```bash
|
||||
cargo test --package attune-cli --tests
|
||||
```
|
||||
|
||||
### Run Specific Test File
|
||||
|
||||
```bash
|
||||
# Authentication tests only
|
||||
cargo test --package attune-cli --test test_auth
|
||||
|
||||
# Pack tests only
|
||||
cargo test --package attune-cli --test test_packs
|
||||
|
||||
# Execution tests only
|
||||
cargo test --package attune-cli --test test_executions
|
||||
```
|
||||
|
||||
### Run Specific Test
|
||||
|
||||
```bash
|
||||
cargo test --package attune-cli --test test_auth test_login_success
|
||||
```
|
||||
|
||||
### Run with Output
|
||||
|
||||
```bash
|
||||
cargo test --package attune-cli --tests -- --nocapture
|
||||
```
|
||||
|
||||
### Run in Parallel (default) or Serial
|
||||
|
||||
```bash
|
||||
# Parallel (faster)
|
||||
cargo test --package attune-cli --tests
|
||||
|
||||
# Serial (for debugging)
|
||||
cargo test --package attune-cli --tests -- --test-threads=1
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Authentication (test_auth.rs)
|
||||
|
||||
- ✅ Login with valid credentials
|
||||
- ✅ Login with invalid credentials
|
||||
- ✅ Whoami when authenticated
|
||||
- ✅ Whoami when unauthenticated
|
||||
- ✅ Logout and token removal
|
||||
- ✅ Profile override with --profile flag
|
||||
- ✅ Missing required arguments
|
||||
- ✅ JSON/YAML output formats
|
||||
|
||||
### Packs (test_packs.rs)
|
||||
|
||||
- ✅ List packs when authenticated
|
||||
- ✅ List packs when unauthenticated
|
||||
- ✅ Get pack by reference
|
||||
- ✅ Pack not found (404)
|
||||
- ✅ Empty pack list
|
||||
- ✅ JSON/YAML output formats
|
||||
- ✅ Profile and API URL overrides
|
||||
|
||||
### Actions (test_actions.rs)
|
||||
|
||||
- ✅ List actions
|
||||
- ✅ Get action details
|
||||
- ✅ Execute action with parameters
|
||||
- ✅ Execute with multiple parameters
|
||||
- ✅ Execute with JSON parameters
|
||||
- ✅ Execute without parameters
|
||||
- ✅ Execute with --wait flag
|
||||
- ✅ Execute with --async flag
|
||||
- ✅ List actions by pack
|
||||
- ✅ Invalid parameter formats
|
||||
- ✅ JSON/YAML output formats
|
||||
|
||||
### Executions (test_executions.rs)
|
||||
|
||||
- ✅ List executions
|
||||
- ✅ Get execution by ID
|
||||
- ✅ Get execution result (raw output)
|
||||
- ✅ Filter by status
|
||||
- ✅ Filter by pack name
|
||||
- ✅ Filter by action
|
||||
- ✅ Multiple filters combined
|
||||
- ✅ Empty execution list
|
||||
- ✅ Invalid execution ID
|
||||
- ✅ JSON/YAML output formats
|
||||
|
||||
### Configuration (test_config.rs)
|
||||
|
||||
- ✅ Show current configuration
|
||||
- ✅ Get specific config key
|
||||
- ✅ Set config values (api_url, output_format)
|
||||
- ✅ List all profiles
|
||||
- ✅ Show specific profile
|
||||
- ✅ Add new profile
|
||||
- ✅ Switch profile (use command)
|
||||
- ✅ Remove profile
|
||||
- ✅ Cannot remove default profile
|
||||
- ✅ Cannot remove active profile
|
||||
- ✅ Profile override with --profile flag
|
||||
- ✅ Profile override with ATTUNE_PROFILE env var
|
||||
- ✅ Sensitive data masking
|
||||
- ✅ JSON/YAML output formats
|
||||
|
||||
### Rules, Triggers, Sensors (test_rules_triggers_sensors.rs)
|
||||
|
||||
- ✅ List rules/triggers/sensors
|
||||
- ✅ Get by reference
|
||||
- ✅ Not found (404)
|
||||
- ✅ List by pack
|
||||
- ✅ Empty results
|
||||
- ✅ JSON/YAML output formats
|
||||
- ✅ Cross-feature profile usage
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_my_feature() {
|
||||
// 1. Create test fixture
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("token", "refresh");
|
||||
|
||||
// 2. Mock API response
|
||||
mock_some_endpoint(&fixture.mock_server).await;
|
||||
|
||||
// 3. Execute CLI command
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("subcommand")
|
||||
.arg("action");
|
||||
|
||||
// 4. Assert results
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("expected output"));
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Custom Mock Responses
|
||||
|
||||
```rust
|
||||
use wiremock::{Mock, ResponseTemplate, matchers::{method, path}};
|
||||
use serde_json::json;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/custom-endpoint"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {"key": "value"}
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
```
|
||||
|
||||
### Testing Error Cases
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_error_case() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
// Mock error response
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/endpoint"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_json(json!({
|
||||
"error": "Internal server error"
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.arg("command");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error"));
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
The integration tests use:
|
||||
|
||||
- **`assert_cmd`** - For testing CLI binaries
|
||||
- **`predicates`** - For flexible assertions
|
||||
- **`wiremock`** - For mocking HTTP API responses
|
||||
- **`tempfile`** - For temporary test directories
|
||||
- **`tokio-test`** - For async test utilities
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
These tests should be run in CI/CD pipelines:
|
||||
|
||||
```yaml
|
||||
# Example GitHub Actions workflow
|
||||
- name: Run CLI Integration Tests
|
||||
run: cargo test --package attune-cli --tests
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests Hanging
|
||||
|
||||
If tests hang, it's likely due to:
|
||||
- Missing mock responses for API endpoints
|
||||
- The CLI waiting for user input (use appropriate flags to avoid interactive prompts)
|
||||
|
||||
### Flaky Tests
|
||||
|
||||
If tests are flaky:
|
||||
- Ensure proper cleanup between tests (fixtures are automatically cleaned up)
|
||||
- Check for race conditions in parallel test execution
|
||||
- Run with `--test-threads=1` to isolate the issue
|
||||
|
||||
### Config File Conflicts
|
||||
|
||||
Each test uses isolated temporary directories, so config conflicts should not occur. If they do:
|
||||
- Verify `XDG_CONFIG_HOME` and `HOME` environment variables are set correctly
|
||||
- Check that the test is using `fixture.config_dir_path()`
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for the test suite:
|
||||
|
||||
- [ ] Add performance benchmarks for CLI commands
|
||||
- [ ] Test shell completion generation
|
||||
- [ ] Test CLI with real API server (optional integration mode)
|
||||
- [ ] Add tests for interactive prompts using `dialoguer`
|
||||
- [ ] Test error recovery and retry logic
|
||||
- [ ] Add tests for verbose/debug logging output
|
||||
- [ ] Test handling of network timeouts and connection errors
|
||||
- [ ] Add property-based tests with `proptest`
|
||||
|
||||
## Documentation
|
||||
|
||||
For more information:
|
||||
- [CLI Usage Guide](../README.md)
|
||||
- [CLI Profile Management](../../../docs/cli-profiles.md)
|
||||
- [API Documentation](../../../docs/api-*.md)
|
||||
- [Main Project README](../../../README.md)
|
||||
445
crates/cli/tests/common/mod.rs
Normal file
445
crates/cli/tests/common/mod.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Test fixture for CLI integration tests
|
||||
pub struct TestFixture {
|
||||
pub mock_server: MockServer,
|
||||
pub config_dir: TempDir,
|
||||
pub config_path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestFixture {
|
||||
/// Create a new test fixture with a mock API server
|
||||
pub async fn new() -> Self {
|
||||
let mock_server = MockServer::start().await;
|
||||
let config_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
|
||||
// Create attune subdirectory to match actual config path structure
|
||||
let attune_dir = config_dir.path().join("attune");
|
||||
std::fs::create_dir_all(&attune_dir).expect("Failed to create attune config dir");
|
||||
let config_path = attune_dir.join("config.yaml");
|
||||
|
||||
Self {
|
||||
mock_server,
|
||||
config_dir,
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the mock server URI
|
||||
pub fn server_url(&self) -> String {
|
||||
self.mock_server.uri()
|
||||
}
|
||||
|
||||
/// Get the config directory path
|
||||
pub fn config_dir_path(&self) -> &std::path::Path {
|
||||
self.config_dir.path()
|
||||
}
|
||||
|
||||
/// Write a test config file with the mock server URL
|
||||
pub fn write_config(&self, content: &str) {
|
||||
std::fs::write(&self.config_path, content).expect("Failed to write config");
|
||||
}
|
||||
|
||||
/// Write a default config with the mock server
|
||||
pub fn write_default_config(&self) {
|
||||
let config = format!(
|
||||
r#"
|
||||
current_profile: default
|
||||
default_output_format: table
|
||||
profiles:
|
||||
default:
|
||||
api_url: {}
|
||||
description: Test server
|
||||
"#,
|
||||
self.server_url()
|
||||
);
|
||||
self.write_config(&config);
|
||||
}
|
||||
|
||||
/// Write a config with authentication tokens
|
||||
pub fn write_authenticated_config(&self, access_token: &str, refresh_token: &str) {
|
||||
let config = format!(
|
||||
r#"
|
||||
current_profile: default
|
||||
default_output_format: table
|
||||
profiles:
|
||||
default:
|
||||
api_url: {}
|
||||
auth_token: {}
|
||||
refresh_token: {}
|
||||
description: Test server
|
||||
"#,
|
||||
self.server_url(),
|
||||
access_token,
|
||||
refresh_token
|
||||
);
|
||||
self.write_config(&config);
|
||||
}
|
||||
|
||||
/// Write a config with multiple profiles
|
||||
#[allow(dead_code)]
|
||||
pub fn write_multi_profile_config(&self) {
|
||||
let config = format!(
|
||||
r#"
|
||||
current_profile: default
|
||||
default_output_format: table
|
||||
profiles:
|
||||
default:
|
||||
api_url: {}
|
||||
description: Default test server
|
||||
staging:
|
||||
api_url: https://staging.example.com
|
||||
description: Staging environment
|
||||
production:
|
||||
api_url: https://api.example.com
|
||||
description: Production environment
|
||||
output_format: json
|
||||
"#,
|
||||
self.server_url()
|
||||
);
|
||||
self.write_config(&config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock a successful login response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_login_success(server: &MockServer, access_token: &str, refresh_token: &str) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/auth/login"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"expires_in": 3600
|
||||
}
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock a failed login response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_login_failure(server: &MockServer) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/auth/login"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": "Invalid credentials"
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock a whoami response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_whoami_success(server: &MockServer, username: &str, email: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/auth/whoami"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "Test User",
|
||||
"username": username,
|
||||
"email": email,
|
||||
"identity_type": "user",
|
||||
"enabled": true,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock an unauthorized response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_unauthorized(server: &MockServer, path_pattern: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(path_pattern))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": "Unauthorized"
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock a pack list response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_pack_list(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/packs"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"ref": "core",
|
||||
"label": "Core Pack",
|
||||
"description": "Core pack",
|
||||
"version": "1.0.0",
|
||||
"author": "Attune",
|
||||
"enabled": true,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"ref": "linux",
|
||||
"label": "Linux Pack",
|
||||
"description": "Linux automation pack",
|
||||
"version": "1.0.0",
|
||||
"author": "Attune",
|
||||
"enabled": true,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock a pack get response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_pack_get(server: &MockServer, pack_ref: &str) {
|
||||
let path_pattern = format!("/api/v1/packs/{}", pack_ref);
|
||||
// Capitalize first letter for label
|
||||
let label = pack_ref
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
if i == 0 {
|
||||
c.to_uppercase().next().unwrap()
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
Mock::given(method("GET"))
|
||||
.and(path(path_pattern.as_str()))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"id": 1,
|
||||
"ref": pack_ref,
|
||||
"label": format!("{} Pack", label),
|
||||
"description": format!("{} pack", pack_ref),
|
||||
"version": "1.0.0",
|
||||
"author": "Attune",
|
||||
"enabled": true,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock an action list response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_action_list(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/actions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"ref": "core.echo",
|
||||
"pack_ref": "core",
|
||||
"label": "Echo Action",
|
||||
"description": "Echo a message",
|
||||
"entrypoint": "echo.py",
|
||||
"runtime": null,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"total": 1,
|
||||
"total_pages": 1
|
||||
}
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock an action execution response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_action_execute(server: &MockServer, execution_id: i64) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/executions/execute"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
|
||||
"data": {
|
||||
"id": execution_id,
|
||||
"action": 1,
|
||||
"action_ref": "core.echo",
|
||||
"config": {},
|
||||
"parent": null,
|
||||
"enforcement": null,
|
||||
"executor": null,
|
||||
"status": "scheduled",
|
||||
"result": null,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock an execution get response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_execution_get(server: &MockServer, execution_id: i64, status: &str) {
|
||||
let path_pattern = format!("/api/v1/executions/{}", execution_id);
|
||||
Mock::given(method("GET"))
|
||||
.and(path(path_pattern.as_str()))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"id": execution_id,
|
||||
"action": 1,
|
||||
"action_ref": "core.echo",
|
||||
"config": {"message": "Hello"},
|
||||
"parent": null,
|
||||
"enforcement": null,
|
||||
"executor": null,
|
||||
"status": status,
|
||||
"result": {"output": "Hello"},
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock an execution list response with filters
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_execution_list(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/executions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"action_ref": "core.echo",
|
||||
"status": "succeeded",
|
||||
"parent": null,
|
||||
"enforcement": null,
|
||||
"result": {"output": "Hello"},
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"action_ref": "core.echo",
|
||||
"status": "failed",
|
||||
"parent": null,
|
||||
"enforcement": null,
|
||||
"result": {"error": "Command failed"},
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock a rule list response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_rule_list(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/rules"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"ref": "core.on_webhook",
|
||||
"pack": 1,
|
||||
"pack_ref": "core",
|
||||
"label": "On Webhook",
|
||||
"description": "Handle webhook events",
|
||||
"trigger": 1,
|
||||
"trigger_ref": "core.webhook",
|
||||
"action": 1,
|
||||
"action_ref": "core.echo",
|
||||
"enabled": true,
|
||||
"conditions": {},
|
||||
"action_params": {},
|
||||
"trigger_params": {},
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock a trigger list response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_trigger_list(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/triggers"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"ref": "core.webhook",
|
||||
"pack": 1,
|
||||
"pack_ref": "core",
|
||||
"label": "Webhook Trigger",
|
||||
"description": "Webhook trigger",
|
||||
"enabled": true,
|
||||
"param_schema": {},
|
||||
"out_schema": {},
|
||||
"webhook_enabled": false,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock a sensor list response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_sensor_list(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/sensors"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"ref": "core.webhook_sensor",
|
||||
"pack": 1,
|
||||
"pack_ref": "core",
|
||||
"label": "Webhook Sensor",
|
||||
"description": "Webhook sensor",
|
||||
"enabled": true,
|
||||
"trigger_types": ["core.webhook"],
|
||||
"entry_point": "webhook_sensor.py",
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Mock a 404 not found response
|
||||
#[allow(dead_code)]
|
||||
pub async fn mock_not_found(server: &MockServer, path_pattern: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(path_pattern))
|
||||
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
|
||||
"error": "Not found"
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
494
crates/cli/tests/pack_registry_tests.rs
Normal file
494
crates/cli/tests/pack_registry_tests.rs
Normal file
@@ -0,0 +1,494 @@
|
||||
//! CLI integration tests for pack registry commands
|
||||
#![allow(deprecated)]
|
||||
|
||||
//!
|
||||
//! This module tests:
|
||||
//! - `attune pack install` command with all sources
|
||||
//! - `attune pack checksum` command
|
||||
//! - `attune pack index-entry` command
|
||||
//! - `attune pack index-update` command
|
||||
//! - `attune pack index-merge` command
|
||||
//! - Error handling and output formatting
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper to create a test pack directory with pack.yaml
|
||||
fn create_test_pack(name: &str, version: &str, deps: &[&str]) -> TempDir {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
let deps_yaml = if deps.is_empty() {
|
||||
"dependencies: []".to_string()
|
||||
} else {
|
||||
let dep_list = deps
|
||||
.iter()
|
||||
.map(|d| format!(" - {}", d))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("dependencies:\n{}", dep_list)
|
||||
};
|
||||
|
||||
let pack_yaml = format!(
|
||||
r#"
|
||||
ref: {}
|
||||
name: Test Pack {}
|
||||
version: {}
|
||||
description: Test pack for CLI integration tests
|
||||
author: Test Author
|
||||
email: test@example.com
|
||||
license: Apache-2.0
|
||||
homepage: https://example.com
|
||||
repository: https://github.com/example/pack
|
||||
keywords:
|
||||
- test
|
||||
- cli
|
||||
{}
|
||||
python: "3.8"
|
||||
actions:
|
||||
test_action:
|
||||
entry_point: test.py
|
||||
runner_type: python-script
|
||||
description: Test action
|
||||
sensors:
|
||||
test_sensor:
|
||||
entry_point: sensor.py
|
||||
runner_type: python-script
|
||||
triggers:
|
||||
test_trigger:
|
||||
description: Test trigger
|
||||
"#,
|
||||
name, name, version, deps_yaml
|
||||
);
|
||||
|
||||
fs::write(temp_dir.path().join("pack.yaml"), pack_yaml).unwrap();
|
||||
fs::write(temp_dir.path().join("test.py"), "print('test action')").unwrap();
|
||||
fs::write(temp_dir.path().join("sensor.py"), "print('test sensor')").unwrap();
|
||||
|
||||
temp_dir
|
||||
}
|
||||
|
||||
/// Helper to create a registry index file
|
||||
fn create_test_index(packs: &[(&str, &str)]) -> TempDir {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
let pack_entries: Vec<String> = packs
|
||||
.iter()
|
||||
.map(|(name, version)| {
|
||||
format!(
|
||||
r#"{{
|
||||
"ref": "{}",
|
||||
"label": "Test Pack {}",
|
||||
"version": "{}",
|
||||
"author": "Test",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["test"],
|
||||
"install_sources": [
|
||||
{{
|
||||
"type": "git",
|
||||
"url": "https://github.com/test/{}.git",
|
||||
"ref": "v{}",
|
||||
"checksum": "sha256:abc123"
|
||||
}}
|
||||
]
|
||||
}}"#,
|
||||
name, name, version, name, version
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let index = format!(
|
||||
r#"{{
|
||||
"version": "1.0",
|
||||
"packs": [
|
||||
{}
|
||||
]
|
||||
}}"#,
|
||||
pack_entries.join(",\n")
|
||||
);
|
||||
|
||||
fs::write(temp_dir.path().join("index.json"), index).unwrap();
|
||||
|
||||
temp_dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_checksum_directory() {
|
||||
let pack_dir = create_test_pack("checksum-test", "1.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("--output")
|
||||
.arg("table")
|
||||
.arg("pack")
|
||||
.arg("checksum")
|
||||
.arg(pack_dir.path().to_str().unwrap());
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("sha256:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_checksum_json_output() {
|
||||
let pack_dir = create_test_pack("checksum-json", "1.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("--output")
|
||||
.arg("json")
|
||||
.arg("pack")
|
||||
.arg("checksum")
|
||||
.arg(pack_dir.path().to_str().unwrap());
|
||||
|
||||
let output = cmd.assert().success();
|
||||
let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
|
||||
|
||||
// Verify it's valid JSON
|
||||
let json: Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert!(json["checksum"].is_string());
|
||||
assert!(json["checksum"].as_str().unwrap().starts_with("sha256:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_checksum_nonexistent_path() {
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack").arg("checksum").arg("/nonexistent/path");
|
||||
|
||||
cmd.assert().failure().stderr(
|
||||
predicate::str::contains("not found").or(predicate::str::contains("does not exist")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_entry_generates_valid_json() {
|
||||
let pack_dir = create_test_pack("index-entry-test", "1.2.3", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("--output")
|
||||
.arg("json")
|
||||
.arg("pack")
|
||||
.arg("index-entry")
|
||||
.arg(pack_dir.path().to_str().unwrap())
|
||||
.arg("--git-url")
|
||||
.arg("https://github.com/test/pack.git")
|
||||
.arg("--git-ref")
|
||||
.arg("v1.2.3");
|
||||
|
||||
let output = cmd.assert().success();
|
||||
let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
|
||||
|
||||
// Verify it's valid JSON
|
||||
let json: Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(json["ref"], "index-entry-test");
|
||||
assert_eq!(json["version"], "1.2.3");
|
||||
assert!(json["install_sources"].is_array());
|
||||
assert!(json["install_sources"][0]["checksum"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("sha256:"));
|
||||
|
||||
// Verify metadata
|
||||
assert_eq!(json["author"], "Test Author");
|
||||
assert_eq!(json["license"], "Apache-2.0");
|
||||
assert!(json["keywords"].as_array().unwrap().len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_entry_with_archive_url() {
|
||||
let pack_dir = create_test_pack("archive-test", "2.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("--output")
|
||||
.arg("json")
|
||||
.arg("pack")
|
||||
.arg("index-entry")
|
||||
.arg(pack_dir.path().to_str().unwrap())
|
||||
.arg("--archive-url")
|
||||
.arg("https://releases.example.com/pack-2.0.0.tar.gz");
|
||||
|
||||
let output = cmd.assert().success();
|
||||
let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
|
||||
|
||||
let json: Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert!(json["install_sources"].as_array().unwrap().len() > 0);
|
||||
|
||||
let archive_source = &json["install_sources"][0];
|
||||
assert_eq!(archive_source["type"], "archive");
|
||||
assert_eq!(
|
||||
archive_source["url"],
|
||||
"https://releases.example.com/pack-2.0.0.tar.gz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_entry_missing_pack_yaml() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
fs::write(temp_dir.path().join("readme.txt"), "No pack.yaml here").unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-entry")
|
||||
.arg(temp_dir.path().to_str().unwrap());
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("pack.yaml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_update_adds_new_entry() {
|
||||
let index_dir = create_test_index(&[("existing-pack", "1.0.0")]);
|
||||
let index_path = index_dir.path().join("index.json");
|
||||
|
||||
let pack_dir = create_test_pack("new-pack", "1.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-update")
|
||||
.arg("--index")
|
||||
.arg(index_path.to_str().unwrap())
|
||||
.arg(pack_dir.path().to_str().unwrap())
|
||||
.arg("--git-url")
|
||||
.arg("https://github.com/test/new-pack.git")
|
||||
.arg("--git-ref")
|
||||
.arg("v1.0.0");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("new-pack"))
|
||||
.stdout(predicate::str::contains("1.0.0"));
|
||||
|
||||
// Verify index was updated
|
||||
let updated_index = fs::read_to_string(&index_path).unwrap();
|
||||
let json: Value = serde_json::from_str(&updated_index).unwrap();
|
||||
assert_eq!(json["packs"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_update_prevents_duplicate_without_flag() {
|
||||
let index_dir = create_test_index(&[("existing-pack", "1.0.0")]);
|
||||
let index_path = index_dir.path().join("index.json");
|
||||
|
||||
let pack_dir = create_test_pack("existing-pack", "1.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-update")
|
||||
.arg("--index")
|
||||
.arg(index_path.to_str().unwrap())
|
||||
.arg(pack_dir.path().to_str().unwrap())
|
||||
.arg("--git-url")
|
||||
.arg("https://github.com/test/existing-pack.git");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("already exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_update_with_update_flag() {
|
||||
let index_dir = create_test_index(&[("existing-pack", "1.0.0")]);
|
||||
let index_path = index_dir.path().join("index.json");
|
||||
|
||||
let pack_dir = create_test_pack("existing-pack", "2.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-update")
|
||||
.arg("--index")
|
||||
.arg(index_path.to_str().unwrap())
|
||||
.arg(pack_dir.path().to_str().unwrap())
|
||||
.arg("--git-url")
|
||||
.arg("https://github.com/test/existing-pack.git")
|
||||
.arg("--git-ref")
|
||||
.arg("v2.0.0")
|
||||
.arg("--update");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("existing-pack"))
|
||||
.stdout(predicate::str::contains("2.0.0"));
|
||||
|
||||
// Verify version was updated
|
||||
let updated_index = fs::read_to_string(&index_path).unwrap();
|
||||
let json: Value = serde_json::from_str(&updated_index).unwrap();
|
||||
let packs = json["packs"].as_array().unwrap();
|
||||
assert_eq!(packs.len(), 1);
|
||||
assert_eq!(packs[0]["version"], "2.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_update_invalid_index_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let bad_index = temp_dir.path().join("bad-index.json");
|
||||
fs::write(&bad_index, "not valid json {").unwrap();
|
||||
|
||||
let pack_dir = create_test_pack("test-pack", "1.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-update")
|
||||
.arg("--index")
|
||||
.arg(bad_index.to_str().unwrap())
|
||||
.arg(pack_dir.path().to_str().unwrap());
|
||||
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_merge_combines_indexes() {
|
||||
let index1 = create_test_index(&[("pack-a", "1.0.0"), ("pack-b", "1.0.0")]);
|
||||
let index2 = create_test_index(&[("pack-c", "1.0.0"), ("pack-d", "1.0.0")]);
|
||||
|
||||
let output_dir = TempDir::new().unwrap();
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
.arg(output_path.to_str().unwrap())
|
||||
.arg(index1.path().join("index.json").to_str().unwrap())
|
||||
.arg(index2.path().join("index.json").to_str().unwrap());
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Merged"))
|
||||
.stdout(predicate::str::contains("2"));
|
||||
|
||||
// Verify merged file
|
||||
let merged_content = fs::read_to_string(&output_path).unwrap();
|
||||
let json: Value = serde_json::from_str(&merged_content).unwrap();
|
||||
assert_eq!(json["packs"].as_array().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_merge_deduplicates() {
|
||||
let index1 = create_test_index(&[("pack-a", "1.0.0"), ("pack-b", "1.0.0")]);
|
||||
let index2 = create_test_index(&[("pack-a", "2.0.0"), ("pack-c", "1.0.0")]);
|
||||
|
||||
let output_dir = TempDir::new().unwrap();
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
.arg(output_path.to_str().unwrap())
|
||||
.arg(index1.path().join("index.json").to_str().unwrap())
|
||||
.arg(index2.path().join("index.json").to_str().unwrap());
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Duplicates resolved"));
|
||||
|
||||
// Verify deduplication (should have 3 unique packs: pack-a, pack-b, pack-c)
|
||||
let merged_content = fs::read_to_string(&output_path).unwrap();
|
||||
let json: Value = serde_json::from_str(&merged_content).unwrap();
|
||||
let packs = json["packs"].as_array().unwrap();
|
||||
assert_eq!(packs.len(), 3);
|
||||
|
||||
// Verify pack-a has the newer version
|
||||
let pack_a = packs.iter().find(|p| p["ref"] == "pack-a").unwrap();
|
||||
assert_eq!(pack_a["version"], "2.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_merge_output_exists_without_force() {
|
||||
let index1 = create_test_index(&[("pack-a", "1.0.0")]);
|
||||
|
||||
let output_dir = TempDir::new().unwrap();
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
fs::write(&output_path, "existing content").unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
.arg(output_path.to_str().unwrap())
|
||||
.arg(index1.path().join("index.json").to_str().unwrap());
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("already exists").or(predicate::str::contains("force")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_merge_with_force_flag() {
|
||||
let index1 = create_test_index(&[("pack-a", "1.0.0")]);
|
||||
|
||||
let output_dir = TempDir::new().unwrap();
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
fs::write(&output_path, "existing content").unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
.arg(output_path.to_str().unwrap())
|
||||
.arg(index1.path().join("index.json").to_str().unwrap())
|
||||
.arg("--force");
|
||||
|
||||
cmd.assert().success();
|
||||
|
||||
// Verify file was overwritten
|
||||
let merged_content = fs::read_to_string(&output_path).unwrap();
|
||||
assert_ne!(merged_content, "existing content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_merge_empty_input_list() {
|
||||
let output_dir = TempDir::new().unwrap();
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
.arg(output_path.to_str().unwrap());
|
||||
|
||||
// Should fail due to missing required inputs
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_index_merge_missing_input_file() {
|
||||
let index1 = create_test_index(&[("pack-a", "1.0.0")]);
|
||||
let output_dir = TempDir::new().unwrap();
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
.arg(output_path.to_str().unwrap())
|
||||
.arg(index1.path().join("index.json").to_str().unwrap())
|
||||
.arg("/nonexistent/index.json");
|
||||
|
||||
// Should succeed but skip missing file (with warning in stderr)
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stderr(predicate::str::contains("Skipping").or(predicate::str::contains("missing")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_commands_help() {
|
||||
let commands = vec![
|
||||
vec!["pack", "checksum", "--help"],
|
||||
vec!["pack", "index-entry", "--help"],
|
||||
vec!["pack", "index-update", "--help"],
|
||||
vec!["pack", "index-merge", "--help"],
|
||||
];
|
||||
|
||||
for args in commands {
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
for arg in &args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Usage:"));
|
||||
}
|
||||
}
|
||||
570
crates/cli/tests/test_actions.rs
Normal file
570
crates/cli/tests/test_actions.rs
Normal file
@@ -0,0 +1,570 @@
|
||||
//! Integration tests for CLI action commands
|
||||
#![allow(deprecated)]
|
||||
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
matchers::{method, path},
|
||||
Mock, ResponseTemplate,
|
||||
};
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_list_authenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action list endpoint
|
||||
mock_action_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("core.echo"))
|
||||
.stdout(predicate::str::contains("Echo a message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_list_unauthenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
// Mock unauthorized response
|
||||
mock_unauthorized(&fixture.mock_server, "/api/v1/actions").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_list_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action list endpoint
|
||||
mock_action_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--json")
|
||||
.arg("action")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""ref""#))
|
||||
.stdout(predicate::str::contains(r#"core.echo"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_list_yaml_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action list endpoint
|
||||
mock_action_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--yaml")
|
||||
.arg("action")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("core.echo"))
|
||||
.stdout(predicate::str::contains("Echo a message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_get_by_ref() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action get endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/actions/core.echo"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"id": 1,
|
||||
"ref": "core.echo",
|
||||
"pack": 1,
|
||||
"pack_ref": "core",
|
||||
"label": "Echo Action",
|
||||
"description": "Echo a message",
|
||||
"entrypoint": "echo.py",
|
||||
"runtime": null,
|
||||
"param_schema": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Message to echo",
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"out_schema": null,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("show")
|
||||
.arg("core.echo");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("core.echo"))
|
||||
.stdout(predicate::str::contains("Echo a message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_get_not_found() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock 404 response
|
||||
mock_not_found(&fixture.mock_server, "/api/v1/actions/nonexistent.action").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("show")
|
||||
.arg("nonexistent.action");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_execute_with_parameters() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action execute endpoint
|
||||
mock_action_execute(&fixture.mock_server, 42).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.arg("core.echo")
|
||||
.arg("--param")
|
||||
.arg("message=Hello World");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("42").or(predicate::str::contains("scheduled")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_execute_multiple_parameters() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action execute endpoint
|
||||
mock_action_execute(&fixture.mock_server, 100).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.arg("linux.run_command")
|
||||
.arg("--param")
|
||||
.arg("cmd=ls -la")
|
||||
.arg("--param")
|
||||
.arg("timeout=30");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_execute_with_json_parameters() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action execute endpoint
|
||||
mock_action_execute(&fixture.mock_server, 101).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.arg("core.webhook")
|
||||
.arg("--params-json")
|
||||
.arg(r#"{"url": "https://example.com", "method": "POST"}"#);
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_execute_without_parameters() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action execute endpoint
|
||||
mock_action_execute(&fixture.mock_server, 200).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.arg("core.no_params_action");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_execute_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action execute endpoint
|
||||
mock_action_execute(&fixture.mock_server, 150).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--json")
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.arg("core.echo")
|
||||
.arg("--param")
|
||||
.arg("message=test");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("150"))
|
||||
.stdout(predicate::str::contains("scheduled"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_execute_wait_for_completion() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action execute endpoint
|
||||
mock_action_execute(&fixture.mock_server, 250).await;
|
||||
|
||||
// Mock execution polling - first running, then succeeded
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/executions/250"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"id": 250,
|
||||
"action": 1,
|
||||
"action_ref": "core.echo",
|
||||
"config": {"message": "test"},
|
||||
"parent": null,
|
||||
"enforcement": null,
|
||||
"executor": null,
|
||||
"status": "succeeded",
|
||||
"result": {"output": "test"},
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.arg("core.echo")
|
||||
.arg("--param")
|
||||
.arg("message=test")
|
||||
.arg("--wait");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("succeeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Profile switching needs more investigation - CLI integration issue"]
|
||||
async fn test_action_execute_with_profile() {
|
||||
let fixture = TestFixture::new().await;
|
||||
|
||||
// Create multi-profile config
|
||||
let config = format!(
|
||||
r#"
|
||||
current_profile: default
|
||||
default_output_format: table
|
||||
profiles:
|
||||
default:
|
||||
api_url: {}
|
||||
auth_token: default_token
|
||||
refresh_token: default_refresh
|
||||
production:
|
||||
api_url: {}
|
||||
auth_token: prod_token
|
||||
refresh_token: prod_refresh
|
||||
"#,
|
||||
fixture.server_url(),
|
||||
fixture.server_url()
|
||||
);
|
||||
fixture.write_config(&config);
|
||||
|
||||
// Mock action execute endpoint
|
||||
mock_action_execute(&fixture.mock_server, 300).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--profile")
|
||||
.arg("production")
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.arg("core.echo")
|
||||
.arg("--param")
|
||||
.arg("message=prod_test");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_execute_invalid_param_format() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.arg("core.echo")
|
||||
.arg("--param")
|
||||
.arg("invalid_format_no_equals");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error").or(predicate::str::contains("=")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_execute_invalid_json_parameters() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.arg("core.echo")
|
||||
.arg("--params-json")
|
||||
.arg(r#"{"invalid json"#);
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error").or(predicate::str::contains("JSON")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_list_by_pack() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action list for a specific pack
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/packs/core/actions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"ref": "core.echo",
|
||||
"pack_ref": "core",
|
||||
"label": "Echo Action",
|
||||
"description": "Echo a message",
|
||||
"entrypoint": "echo.py",
|
||||
"runtime": null,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"total": 1,
|
||||
"total_pages": 1
|
||||
}
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("list")
|
||||
.arg("--pack")
|
||||
.arg("core");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_execute_async_flag() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action execute endpoint
|
||||
mock_action_execute(&fixture.mock_server, 400).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.arg("core.long_running");
|
||||
// Note: default behavior is async (no --wait), so no --async flag needed
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("scheduled").or(predicate::str::contains("400")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_list_empty_result() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock empty action list
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/actions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": []
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_action_get_shows_parameters() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock action get with detailed parameters
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/actions/core.complex"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"id": 5,
|
||||
"ref": "core.complex",
|
||||
"pack": 1,
|
||||
"pack_ref": "core",
|
||||
"label": "Complex Action",
|
||||
"description": "Complex action with multiple params",
|
||||
"entrypoint": "complex.py",
|
||||
"runtime": null,
|
||||
"param_schema": {
|
||||
"required_string": {
|
||||
"type": "string",
|
||||
"description": "A required string parameter",
|
||||
"required": true
|
||||
},
|
||||
"optional_number": {
|
||||
"type": "integer",
|
||||
"description": "An optional number",
|
||||
"required": false,
|
||||
"default": 42
|
||||
},
|
||||
"boolean_flag": {
|
||||
"type": "boolean",
|
||||
"description": "A boolean flag",
|
||||
"required": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"out_schema": null,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("action")
|
||||
.arg("show")
|
||||
.arg("core.complex");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("required_string"))
|
||||
.stdout(predicate::str::contains("optional_number"));
|
||||
}
|
||||
226
crates/cli/tests/test_auth.rs
Normal file
226
crates/cli/tests/test_auth.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! Integration tests for CLI authentication commands
|
||||
|
||||
#![allow(deprecated)]
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_login_success() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
// Mock successful login
|
||||
mock_login_success(
|
||||
&fixture.mock_server,
|
||||
"test_access_token",
|
||||
"test_refresh_token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("auth")
|
||||
.arg("login")
|
||||
.arg("--username")
|
||||
.arg("testuser")
|
||||
.arg("--password")
|
||||
.arg("testpass");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Successfully logged in"));
|
||||
|
||||
// Verify tokens were saved to config
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("test_access_token"));
|
||||
assert!(config_content.contains("test_refresh_token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_login_failure() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
// Mock failed login
|
||||
mock_login_failure(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("auth")
|
||||
.arg("login")
|
||||
.arg("--username")
|
||||
.arg("baduser")
|
||||
.arg("--password")
|
||||
.arg("badpass");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_authenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock whoami endpoint
|
||||
mock_whoami_success(&fixture.mock_server, "testuser", "test@example.com").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("auth")
|
||||
.arg("whoami");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("testuser"))
|
||||
.stdout(predicate::str::contains("test@example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_unauthenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
// Mock unauthorized response
|
||||
mock_unauthorized(&fixture.mock_server, "/auth/whoami").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("auth")
|
||||
.arg("whoami");
|
||||
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_logout() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Verify tokens exist before logout
|
||||
let config_before =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_before.contains("valid_token"));
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("auth")
|
||||
.arg("logout");
|
||||
|
||||
cmd.assert().success().stdout(
|
||||
predicate::str::contains("logged out")
|
||||
.or(predicate::str::contains("Successfully logged out")),
|
||||
);
|
||||
|
||||
// Verify tokens were removed from config
|
||||
let config_after =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(!config_after.contains("valid_token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_login_with_profile_override() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
// Mock successful login
|
||||
mock_login_success(&fixture.mock_server, "staging_token", "staging_refresh").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--profile")
|
||||
.arg("default")
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("auth")
|
||||
.arg("login")
|
||||
.arg("--username")
|
||||
.arg("testuser")
|
||||
.arg("--password")
|
||||
.arg("testpass");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_login_missing_username() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.arg("auth")
|
||||
.arg("login")
|
||||
.arg("--password")
|
||||
.arg("testpass");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock whoami endpoint
|
||||
mock_whoami_success(&fixture.mock_server, "testuser", "test@example.com").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--json")
|
||||
.arg("auth")
|
||||
.arg("whoami");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""username":"#))
|
||||
.stdout(predicate::str::contains("testuser"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_yaml_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock whoami endpoint
|
||||
mock_whoami_success(&fixture.mock_server, "testuser", "test@example.com").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--yaml")
|
||||
.arg("auth")
|
||||
.arg("whoami");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("username:"))
|
||||
.stdout(predicate::str::contains("testuser"));
|
||||
}
|
||||
522
crates/cli/tests/test_config.rs
Normal file
522
crates/cli/tests/test_config.rs
Normal file
@@ -0,0 +1,522 @@
|
||||
//! Integration tests for CLI config and profile management commands
|
||||
#![allow(deprecated)]
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_show_default() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("current_profile"))
|
||||
.stdout(predicate::str::contains("api_url"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_show_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--json")
|
||||
.arg("config")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""current_profile""#))
|
||||
.stdout(predicate::str::contains(r#""api_url""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_show_yaml_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--yaml")
|
||||
.arg("config")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("current_profile:"))
|
||||
.stdout(predicate::str::contains("api_url:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_get_specific_key() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("get")
|
||||
.arg("api_url");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(fixture.server_url()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_get_nonexistent_key() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("get")
|
||||
.arg("nonexistent_key");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_set_api_url() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("set")
|
||||
.arg("api_url")
|
||||
.arg("https://new-api.example.com");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Configuration updated"));
|
||||
|
||||
// Verify the change was persisted
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("https://new-api.example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_set_output_format() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("set")
|
||||
.arg("output_format")
|
||||
.arg("json");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Configuration updated"));
|
||||
|
||||
// Verify the change was persisted
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("output_format: json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_list() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("profiles");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("default"))
|
||||
.stdout(predicate::str::contains("staging"))
|
||||
.stdout(predicate::str::contains("production"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_list_shows_current() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("profiles");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("*").or(predicate::str::contains("(active)")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_show_specific() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("show-profile")
|
||||
.arg("staging");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("staging.example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_show_nonexistent() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("show-profile")
|
||||
.arg("nonexistent");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_add_new() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("add-profile")
|
||||
.arg("testing")
|
||||
.arg("--api-url")
|
||||
.arg("https://test.example.com")
|
||||
.arg("--description")
|
||||
.arg("Testing environment");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Profile 'testing' added"));
|
||||
|
||||
// Verify the profile was added
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("testing:"));
|
||||
assert!(config_content.contains("https://test.example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_add_without_description() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("add-profile")
|
||||
.arg("newprofile")
|
||||
.arg("--api-url")
|
||||
.arg("https://new.example.com");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Profile 'newprofile' added"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_use_switch() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("use")
|
||||
.arg("staging");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Switched to profile 'staging'"));
|
||||
|
||||
// Verify the current profile was changed
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("current_profile: staging"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_use_nonexistent() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("use")
|
||||
.arg("nonexistent");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("does not exist"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_remove() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("remove-profile")
|
||||
.arg("staging");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Profile 'staging' removed"));
|
||||
|
||||
// Verify the profile was removed
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(!config_content.contains("staging:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_remove_default_fails() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("remove-profile")
|
||||
.arg("default");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Cannot remove"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_remove_active_fails() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
// Try to remove the currently active profile
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("remove-profile")
|
||||
.arg("default");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Cannot remove active profile"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_remove_nonexistent() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("remove-profile")
|
||||
.arg("nonexistent");
|
||||
|
||||
cmd.assert().success(); // Removing non-existent profile might be a no-op
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_override_with_flag() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
// Use --profile flag to temporarily override
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--profile")
|
||||
.arg("staging")
|
||||
.arg("config")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().success();
|
||||
|
||||
// Verify current profile wasn't changed in the config file
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("current_profile: default"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_override_with_env_var() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
// Use ATTUNE_PROFILE env var to temporarily override
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.env("ATTUNE_PROFILE", "production")
|
||||
.arg("config")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().success();
|
||||
|
||||
// Verify current profile wasn't changed in the config file
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("current_profile: default"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_with_custom_output_format() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
// Switch to production which has json output format
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("use")
|
||||
.arg("production");
|
||||
|
||||
cmd.assert().success();
|
||||
|
||||
// Verify the profile has custom output format
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("output_format: json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_list_all_keys() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("test_token", "test_refresh");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("api_url"))
|
||||
.stdout(predicate::str::contains("output_format"))
|
||||
.stdout(predicate::str::contains("auth_token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_masks_sensitive_data() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("secret_token_123", "secret_refresh_456");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("get")
|
||||
.arg("auth_token");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("***"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_add_duplicate_overwrites() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
// Add a profile with the same name as existing one
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("add-profile")
|
||||
.arg("staging")
|
||||
.arg("--api-url")
|
||||
.arg("https://new-staging.example.com");
|
||||
|
||||
cmd.assert().success();
|
||||
|
||||
// Verify the profile was updated
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("https://new-staging.example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_list_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--json")
|
||||
.arg("config")
|
||||
.arg("profiles");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""default""#))
|
||||
.stdout(predicate::str::contains(r#""staging""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_path_display() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("path");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("config.yaml"));
|
||||
}
|
||||
463
crates/cli/tests/test_executions.rs
Normal file
463
crates/cli/tests/test_executions.rs
Normal file
@@ -0,0 +1,463 @@
|
||||
//! Integration tests for CLI execution commands
|
||||
#![allow(deprecated)]
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_list_authenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock execution list endpoint
|
||||
mock_execution_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("succeeded"))
|
||||
.stdout(predicate::str::contains("failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_list_unauthenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
// Mock unauthorized response
|
||||
mock_unauthorized(&fixture.mock_server, "/api/v1/executions").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_list_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock execution list endpoint
|
||||
mock_execution_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--json")
|
||||
.arg("execution")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""status": "succeeded""#))
|
||||
.stdout(predicate::str::contains(r#""status": "failed""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_list_yaml_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock execution list endpoint
|
||||
mock_execution_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--yaml")
|
||||
.arg("execution")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("status: succeeded"))
|
||||
.stdout(predicate::str::contains("status: failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_get_by_id() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock execution get endpoint
|
||||
mock_execution_get(&fixture.mock_server, 123, "succeeded").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("show")
|
||||
.arg("123");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("succeeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_get_not_found() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock 404 response
|
||||
mock_not_found(&fixture.mock_server, "/api/v1/executions/999").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("show")
|
||||
.arg("999");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_list_with_status_filter() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock execution list with filter
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
matchers::{method, path, query_param},
|
||||
Mock, ResponseTemplate,
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/executions"))
|
||||
.and(query_param("status", "succeeded"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"action_ref": "core.echo",
|
||||
"status": "succeeded",
|
||||
"parent": null,
|
||||
"enforcement": null,
|
||||
"result": {"output": "Hello"},
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("list")
|
||||
.arg("--status")
|
||||
.arg("succeeded");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("succeeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_result_raw_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock execution get endpoint with result
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
matchers::{method, path},
|
||||
Mock, ResponseTemplate,
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/executions/123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"id": 123,
|
||||
"action_ref": "core.echo",
|
||||
"status": "succeeded",
|
||||
"config": {"message": "Hello"},
|
||||
"result": {"output": "Hello World", "exit_code": 0},
|
||||
"parent": null,
|
||||
"enforcement": null,
|
||||
"executor": null,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("result")
|
||||
.arg("123");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Hello World"))
|
||||
.stdout(predicate::str::contains("exit_code"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_list_with_pack_filter() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock execution list with pack filter
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
matchers::{method, path, query_param},
|
||||
Mock, ResponseTemplate,
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/executions"))
|
||||
.and(query_param("pack_name", "core"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"action_ref": "core.echo",
|
||||
"status": "succeeded",
|
||||
"parent": null,
|
||||
"enforcement": null,
|
||||
"result": {"output": "Test output"},
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("list")
|
||||
.arg("--pack")
|
||||
.arg("core");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_list_with_action_filter() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock execution list with action filter
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
matchers::{method, path, query_param},
|
||||
Mock, ResponseTemplate,
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/executions"))
|
||||
.and(query_param("action_ref", "core.echo"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"action_ref": "core.echo",
|
||||
"status": "succeeded",
|
||||
"parent": null,
|
||||
"enforcement": null,
|
||||
"result": {"output": "Echo test"},
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("list")
|
||||
.arg("--action")
|
||||
.arg("core.echo");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_list_multiple_filters() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock execution list with multiple filters
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
matchers::{method, path, query_param},
|
||||
Mock, ResponseTemplate,
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/executions"))
|
||||
.and(query_param("status", "succeeded"))
|
||||
.and(query_param("pack_name", "core"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"action_ref": "core.echo",
|
||||
"status": "succeeded",
|
||||
"parent": null,
|
||||
"enforcement": null,
|
||||
"result": {},
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("list")
|
||||
.arg("--status")
|
||||
.arg("succeeded")
|
||||
.arg("--pack")
|
||||
.arg("core");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_get_with_profile() {
|
||||
let fixture = TestFixture::new().await;
|
||||
|
||||
// Create multi-profile config
|
||||
let config = format!(
|
||||
r#"
|
||||
current_profile: default
|
||||
default_output_format: table
|
||||
profiles:
|
||||
default:
|
||||
api_url: {}
|
||||
auth_token: valid_token
|
||||
refresh_token: refresh_token
|
||||
description: Default server
|
||||
production:
|
||||
api_url: {}
|
||||
auth_token: prod_token
|
||||
refresh_token: prod_refresh
|
||||
description: Production server
|
||||
"#,
|
||||
fixture.server_url(),
|
||||
fixture.server_url()
|
||||
);
|
||||
fixture.write_config(&config);
|
||||
|
||||
// Mock execution get endpoint
|
||||
mock_execution_get(&fixture.mock_server, 456, "running").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--profile")
|
||||
.arg("production")
|
||||
.arg("execution")
|
||||
.arg("show")
|
||||
.arg("456");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("running"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_list_empty_result() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock empty execution list
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
matchers::{method, path},
|
||||
Mock, ResponseTemplate,
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/executions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": []
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_get_invalid_id() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("execution")
|
||||
.arg("show")
|
||||
.arg("not_a_number");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("invalid"));
|
||||
}
|
||||
254
crates/cli/tests/test_packs.rs
Normal file
254
crates/cli/tests/test_packs.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
//! Integration tests for CLI pack commands
|
||||
|
||||
#![allow(deprecated)]
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_list_authenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock pack list endpoint
|
||||
mock_pack_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("pack")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("core"))
|
||||
.stdout(predicate::str::contains("linux"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_list_unauthenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
// Mock unauthorized response
|
||||
mock_unauthorized(&fixture.mock_server, "/api/v1/packs").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("pack")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_list_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock pack list endpoint
|
||||
mock_pack_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--json")
|
||||
.arg("pack")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""ref": "core""#))
|
||||
.stdout(predicate::str::contains(r#""ref": "linux""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_list_yaml_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock pack list endpoint
|
||||
mock_pack_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--yaml")
|
||||
.arg("pack")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("ref: core"))
|
||||
.stdout(predicate::str::contains("ref: linux"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_get_by_ref() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock pack get endpoint
|
||||
mock_pack_get(&fixture.mock_server, "core").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("pack")
|
||||
.arg("show")
|
||||
.arg("core");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("core"))
|
||||
.stdout(predicate::str::contains("core pack"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_get_not_found() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock 404 response
|
||||
mock_not_found(&fixture.mock_server, "/api/v1/packs/nonexistent").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("pack")
|
||||
.arg("show")
|
||||
.arg("nonexistent");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_list_with_profile() {
|
||||
let fixture = TestFixture::new().await;
|
||||
|
||||
// Create multi-profile config with authentication on default
|
||||
let config = format!(
|
||||
r#"
|
||||
current_profile: staging
|
||||
default_output_format: table
|
||||
profiles:
|
||||
default:
|
||||
api_url: {}
|
||||
auth_token: valid_token
|
||||
refresh_token: refresh_token
|
||||
description: Default server
|
||||
staging:
|
||||
api_url: {}
|
||||
auth_token: staging_token
|
||||
refresh_token: staging_refresh
|
||||
description: Staging server
|
||||
"#,
|
||||
fixture.server_url(),
|
||||
fixture.server_url()
|
||||
);
|
||||
fixture.write_config(&config);
|
||||
|
||||
// Mock pack list endpoint
|
||||
mock_pack_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--profile")
|
||||
.arg("staging")
|
||||
.arg("pack")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_list_with_api_url_override() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock pack list endpoint
|
||||
mock_pack_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("pack")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_get_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock pack get endpoint
|
||||
mock_pack_get(&fixture.mock_server, "core").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("-j")
|
||||
.arg("pack")
|
||||
.arg("show")
|
||||
.arg("core");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""ref": "core""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pack_list_empty_result() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock empty pack list
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
matchers::{method, path},
|
||||
Mock, ResponseTemplate,
|
||||
};
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/packs"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": []
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("pack")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
631
crates/cli/tests/test_rules_triggers_sensors.rs
Normal file
631
crates/cli/tests/test_rules_triggers_sensors.rs
Normal file
@@ -0,0 +1,631 @@
|
||||
//! Integration tests for CLI rules, triggers, and sensors commands
|
||||
#![allow(deprecated)]
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
matchers::{method, path},
|
||||
Mock, ResponseTemplate,
|
||||
};
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
// ============================================================================
|
||||
// Rule Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rule_list_authenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock rule list endpoint
|
||||
mock_rule_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("rule")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("On Webhook"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rule_list_unauthenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
// Mock unauthorized response
|
||||
mock_unauthorized(&fixture.mock_server, "/api/v1/rules").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("rule")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rule_list_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock rule list endpoint
|
||||
mock_rule_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--json")
|
||||
.arg("rule")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""ref": "core.on_webhook""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rule_list_yaml_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock rule list endpoint
|
||||
mock_rule_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--yaml")
|
||||
.arg("rule")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("ref: core.on_webhook"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rule_get_by_ref() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock rule get endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/rules/core.on_webhook"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"id": 1,
|
||||
"ref": "core.on_webhook",
|
||||
"pack": 1,
|
||||
"pack_ref": "core",
|
||||
"label": "On Webhook",
|
||||
"description": "Handle webhook events",
|
||||
"trigger": 1,
|
||||
"trigger_ref": "core.webhook",
|
||||
"action": 1,
|
||||
"action_ref": "core.echo",
|
||||
"enabled": true,
|
||||
"conditions": {},
|
||||
"action_params": {},
|
||||
"trigger_params": {},
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("rule")
|
||||
.arg("show")
|
||||
.arg("core.on_webhook");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("On Webhook"))
|
||||
.stdout(predicate::str::contains("Handle webhook events"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rule_get_not_found() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock 404 response
|
||||
mock_not_found(&fixture.mock_server, "/api/v1/rules/nonexistent.rule").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("rule")
|
||||
.arg("show")
|
||||
.arg("nonexistent.rule");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rule_list_by_pack() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock rule list endpoint with pack filter via query parameter
|
||||
mock_rule_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("rule")
|
||||
.arg("list")
|
||||
.arg("--pack")
|
||||
.arg("core");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trigger Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_trigger_list_authenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock trigger list endpoint
|
||||
mock_trigger_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("trigger")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Webhook Trigger"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_trigger_list_unauthenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
// Mock unauthorized response
|
||||
mock_unauthorized(&fixture.mock_server, "/api/v1/triggers").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("trigger")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_trigger_list_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock trigger list endpoint
|
||||
mock_trigger_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--json")
|
||||
.arg("trigger")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""ref": "core.webhook""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_trigger_list_yaml_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock trigger list endpoint
|
||||
mock_trigger_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--yaml")
|
||||
.arg("trigger")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("ref: core.webhook"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_trigger_get_by_ref() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock trigger get endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/triggers/core.webhook"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"id": 1,
|
||||
"ref": "core.webhook",
|
||||
"pack": 1,
|
||||
"pack_ref": "core",
|
||||
"label": "Webhook Trigger",
|
||||
"description": "Webhook trigger",
|
||||
"enabled": true,
|
||||
"param_schema": {},
|
||||
"out_schema": {},
|
||||
"webhook_enabled": false,
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("trigger")
|
||||
.arg("show")
|
||||
.arg("core.webhook");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Webhook Trigger"))
|
||||
.stdout(predicate::str::contains("Webhook trigger"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_trigger_get_not_found() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock 404 response
|
||||
mock_not_found(&fixture.mock_server, "/api/v1/triggers/nonexistent.trigger").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("trigger")
|
||||
.arg("show")
|
||||
.arg("nonexistent.trigger");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error"));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sensor Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sensor_list_authenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock sensor list endpoint
|
||||
mock_sensor_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("sensor")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Webhook Sensor"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sensor_list_unauthenticated() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
// Mock unauthorized response
|
||||
mock_unauthorized(&fixture.mock_server, "/api/v1/sensors").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("sensor")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sensor_list_json_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock sensor list endpoint
|
||||
mock_sensor_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--json")
|
||||
.arg("sensor")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""ref": "core.webhook_sensor""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sensor_list_yaml_output() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock sensor list endpoint
|
||||
mock_sensor_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("--yaml")
|
||||
.arg("sensor")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("ref: core.webhook_sensor"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sensor_get_by_ref() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock sensor get endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/sensors/core.webhook_sensor"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": {
|
||||
"id": 1,
|
||||
"ref": "core.webhook_sensor",
|
||||
"pack": 1,
|
||||
"pack_ref": "core",
|
||||
"label": "Webhook Sensor",
|
||||
"description": "Webhook sensor",
|
||||
"enabled": true,
|
||||
"trigger_types": ["core.webhook"],
|
||||
"entry_point": "webhook_sensor.py",
|
||||
"created": "2024-01-01T00:00:00Z",
|
||||
"updated": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("sensor")
|
||||
.arg("show")
|
||||
.arg("core.webhook_sensor");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Webhook Sensor"))
|
||||
.stdout(predicate::str::contains("Webhook sensor"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sensor_get_not_found() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock 404 response
|
||||
mock_not_found(&fixture.mock_server, "/api/v1/sensors/nonexistent.sensor").await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("sensor")
|
||||
.arg("show")
|
||||
.arg("nonexistent.sensor");
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sensor_list_by_pack() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock sensor list endpoint with pack filter via query parameter
|
||||
mock_sensor_list(&fixture.mock_server).await;
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("sensor")
|
||||
.arg("list")
|
||||
.arg("--pack")
|
||||
.arg("core");
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cross-feature Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_all_list_commands_with_profile() {
|
||||
let fixture = TestFixture::new().await;
|
||||
|
||||
// Create multi-profile config
|
||||
let config = format!(
|
||||
r#"
|
||||
current_profile: default
|
||||
default_output_format: table
|
||||
profiles:
|
||||
default:
|
||||
api_url: {}
|
||||
auth_token: default_token
|
||||
refresh_token: default_refresh
|
||||
staging:
|
||||
api_url: {}
|
||||
auth_token: staging_token
|
||||
refresh_token: staging_refresh
|
||||
"#,
|
||||
fixture.server_url(),
|
||||
fixture.server_url()
|
||||
);
|
||||
fixture.write_config(&config);
|
||||
|
||||
// Mock all list endpoints
|
||||
mock_rule_list(&fixture.mock_server).await;
|
||||
mock_trigger_list(&fixture.mock_server).await;
|
||||
mock_sensor_list(&fixture.mock_server).await;
|
||||
|
||||
// Test rule list with profile
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--profile")
|
||||
.arg("staging")
|
||||
.arg("rule")
|
||||
.arg("list");
|
||||
cmd.assert().success();
|
||||
|
||||
// Test trigger list with profile
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--profile")
|
||||
.arg("staging")
|
||||
.arg("trigger")
|
||||
.arg("list");
|
||||
cmd.assert().success();
|
||||
|
||||
// Test sensor list with profile
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--profile")
|
||||
.arg("staging")
|
||||
.arg("sensor")
|
||||
.arg("list");
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_list_results() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_authenticated_config("valid_token", "refresh_token");
|
||||
|
||||
// Mock empty lists
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/rules"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": []})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/triggers"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": []})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/sensors"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": []})))
|
||||
.mount(&fixture.mock_server)
|
||||
.await;
|
||||
|
||||
// All should succeed with empty results
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("rule")
|
||||
.arg("list");
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("trigger")
|
||||
.arg("list");
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("--api-url")
|
||||
.arg(fixture.server_url())
|
||||
.arg("sensor")
|
||||
.arg("list");
|
||||
cmd.assert().success();
|
||||
}
|
||||
Reference in New Issue
Block a user