re-uploading work

This commit is contained in:
2026-02-04 17:46:30 -06:00
commit 3b14c65998
1388 changed files with 381262 additions and 0 deletions

65
crates/cli/Cargo.toml Normal file
View 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
View 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
View 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());
}
}

View 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(&params)?);
}
}
}
}
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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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;

File diff suppressed because it is too large Load Diff

View 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(())
}

View 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(&params)?)
} else {
None
};
let trigger_params_json = if let Some(params) = trigger_params {
Some(serde_json::from_str(&params)?)
} 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(())
}

View 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(())
}

View 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
View 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
View 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
View 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");
}
}

View 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
View 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)

View 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;
}

View 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:"));
}
}

View 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"));
}

View 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"));
}

View 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"));
}

View 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"));
}

View 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();
}

View 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();
}