re-uploading work
This commit is contained in:
323
crates/cli/src/client.rs
Normal file
323
crates/cli/src/client.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::{Client as HttpClient, Method, RequestBuilder, Response, StatusCode};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::CliConfig;
|
||||
|
||||
/// API client for interacting with Attune API
|
||||
pub struct ApiClient {
|
||||
client: HttpClient,
|
||||
base_url: String,
|
||||
auth_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Standard API response wrapper
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
/// API error response
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct ApiError {
|
||||
pub error: String,
|
||||
#[serde(default)]
|
||||
pub _details: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
/// Create a new API client from configuration
|
||||
pub fn from_config(config: &CliConfig, api_url_override: &Option<String>) -> Self {
|
||||
let base_url = config.effective_api_url(api_url_override);
|
||||
let auth_token = config.auth_token().ok().flatten();
|
||||
let refresh_token = config.refresh_token().ok().flatten();
|
||||
let config_path = CliConfig::config_path().ok();
|
||||
|
||||
Self {
|
||||
client: HttpClient::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client"),
|
||||
base_url,
|
||||
auth_token,
|
||||
refresh_token,
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new API client
|
||||
#[cfg(test)]
|
||||
pub fn new(base_url: String, auth_token: Option<String>) -> Self {
|
||||
let client = HttpClient::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client");
|
||||
|
||||
Self {
|
||||
client,
|
||||
base_url,
|
||||
auth_token,
|
||||
refresh_token: None,
|
||||
config_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the authentication token
|
||||
#[cfg(test)]
|
||||
pub fn set_auth_token(&mut self, token: String) {
|
||||
self.auth_token = Some(token);
|
||||
}
|
||||
|
||||
/// Clear the authentication token
|
||||
#[cfg(test)]
|
||||
pub fn clear_auth_token(&mut self) {
|
||||
self.auth_token = None;
|
||||
}
|
||||
|
||||
/// Refresh the authentication token using the refresh token
|
||||
///
|
||||
/// Returns Ok(true) if refresh succeeded, Ok(false) if no refresh token available
|
||||
async fn refresh_auth_token(&mut self) -> Result<bool> {
|
||||
let refresh_token = match &self.refresh_token {
|
||||
Some(token) => token.clone(),
|
||||
None => return Ok(false), // No refresh token available
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest {
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
// Build refresh request without auth token
|
||||
let url = format!("{}/auth/refresh", self.base_url);
|
||||
let req = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&RefreshRequest { refresh_token });
|
||||
|
||||
let response = req.send().await.context("Failed to refresh token")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
// Refresh failed - clear tokens
|
||||
self.auth_token = None;
|
||||
self.refresh_token = None;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let api_response: ApiResponse<TokenResponse> = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse refresh response")?;
|
||||
|
||||
// Update in-memory tokens
|
||||
self.auth_token = Some(api_response.data.access_token.clone());
|
||||
self.refresh_token = Some(api_response.data.refresh_token.clone());
|
||||
|
||||
// Persist to config file if we have the path
|
||||
if self.config_path.is_some() {
|
||||
if let Ok(mut config) = CliConfig::load() {
|
||||
let _ = config.set_auth(
|
||||
api_response.data.access_token,
|
||||
api_response.data.refresh_token,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Build a request with common headers
|
||||
fn build_request(&self, method: Method, path: &str) -> RequestBuilder {
|
||||
// Auth endpoints are at /auth, not /auth
|
||||
let url = if path.starts_with("/auth") {
|
||||
format!("{}{}", self.base_url, path)
|
||||
} else {
|
||||
format!("{}/api/v1{}", self.base_url, path)
|
||||
};
|
||||
let mut req = self.client.request(method, &url);
|
||||
|
||||
if let Some(token) = &self.auth_token {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
|
||||
req
|
||||
}
|
||||
|
||||
/// Execute a request and handle the response with automatic token refresh
|
||||
async fn execute<T: DeserializeOwned>(&mut self, req: RequestBuilder) -> Result<T> {
|
||||
let response = req.send().await.context("Failed to send request to API")?;
|
||||
|
||||
// If 401 and we have a refresh token, try to refresh once
|
||||
if response.status() == StatusCode::UNAUTHORIZED && self.refresh_token.is_some() {
|
||||
// Try to refresh the token
|
||||
if self.refresh_auth_token().await? {
|
||||
// Rebuild and retry the original request with new token
|
||||
// Note: This is a simplified retry - the original request body is already consumed
|
||||
// For a production implementation, we'd need to clone the request or store the body
|
||||
return Err(anyhow::anyhow!(
|
||||
"Token expired and was refreshed. Please retry your command."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.handle_response(response).await
|
||||
}
|
||||
|
||||
/// Handle API response and extract data
|
||||
async fn handle_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
let api_response: ApiResponse<T> = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse API response")?;
|
||||
Ok(api_response.data)
|
||||
} else {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Try to parse as API error
|
||||
if let Ok(api_error) = serde_json::from_str::<ApiError>(&error_text) {
|
||||
anyhow::bail!("API error ({}): {}", status, api_error.error);
|
||||
} else {
|
||||
anyhow::bail!("API error ({}): {}", status, error_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET request
|
||||
pub async fn get<T: DeserializeOwned>(&mut self, path: &str) -> Result<T> {
|
||||
let req = self.build_request(Method::GET, path);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// GET request with query parameters (query string must be in path)
|
||||
///
|
||||
/// Part of REST client API - reserved for future advanced filtering/search features.
|
||||
/// Example: `client.get_with_query("/actions?enabled=true&pack=core").await`
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_with_query<T: DeserializeOwned>(&mut self, path: &str) -> Result<T> {
|
||||
let req = self.build_request(Method::GET, path);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// POST request with JSON body
|
||||
pub async fn post<T: DeserializeOwned, B: Serialize>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T> {
|
||||
let req = self.build_request(Method::POST, path).json(body);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// PUT request with JSON body
|
||||
///
|
||||
/// Part of REST client API - will be used for update operations
|
||||
pub async fn put<T: DeserializeOwned, B: Serialize>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T> {
|
||||
let req = self.build_request(Method::PUT, path).json(body);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// PATCH request with JSON body
|
||||
pub async fn patch<T: DeserializeOwned, B: Serialize>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T> {
|
||||
let req = self.build_request(Method::PATCH, path).json(body);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// DELETE request with response parsing
|
||||
///
|
||||
/// Part of REST client API - reserved for delete operations that return data.
|
||||
/// Currently we use `delete_no_response()` for all delete operations.
|
||||
/// This method is kept for API completeness and future use cases where
|
||||
/// delete operations return metadata (e.g., cascade deletion summaries).
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete<T: DeserializeOwned>(&mut self, path: &str) -> Result<T> {
|
||||
let req = self.build_request(Method::DELETE, path);
|
||||
self.execute(req).await
|
||||
}
|
||||
|
||||
/// POST request without expecting response body
|
||||
///
|
||||
/// Part of REST client API - reserved for fire-and-forget operations.
|
||||
/// Example use cases: webhook notifications, event submissions, audit logging.
|
||||
/// Kept for API completeness even though not currently used.
|
||||
#[allow(dead_code)]
|
||||
pub async fn post_no_response<B: Serialize>(&mut self, path: &str, body: &B) -> Result<()> {
|
||||
let req = self.build_request(Method::POST, path).json(body);
|
||||
let response = req.send().await.context("Failed to send request to API")?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
anyhow::bail!("API error ({}): {}", status, error_text);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE request without expecting response body
|
||||
pub async fn delete_no_response(&mut self, path: &str) -> Result<()> {
|
||||
let req = self.build_request(Method::DELETE, path);
|
||||
let response = req.send().await.context("Failed to send request to API")?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
anyhow::bail!("API error ({}): {}", status, error_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_creation() {
|
||||
let client = ApiClient::new("http://localhost:8080".to_string(), None);
|
||||
assert_eq!(client.base_url, "http://localhost:8080");
|
||||
assert!(client.auth_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_auth_token() {
|
||||
let mut client = ApiClient::new("http://localhost:8080".to_string(), None);
|
||||
assert!(client.auth_token.is_none());
|
||||
|
||||
client.set_auth_token("test_token".to_string());
|
||||
assert_eq!(client.auth_token, Some("test_token".to_string()));
|
||||
|
||||
client.clear_auth_token();
|
||||
assert!(client.auth_token.is_none());
|
||||
}
|
||||
}
|
||||
521
crates/cli/src/commands/action.rs
Normal file
521
crates/cli/src/commands/action.rs
Normal file
@@ -0,0 +1,521 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ActionCommands {
|
||||
/// List all actions
|
||||
List {
|
||||
/// Filter by pack name
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
|
||||
/// Filter by action name
|
||||
#[arg(short, long)]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Show details of a specific action
|
||||
Show {
|
||||
/// Action reference (pack.action or ID)
|
||||
action_ref: String,
|
||||
},
|
||||
/// Update an action
|
||||
Update {
|
||||
/// Action reference (pack.action or ID)
|
||||
action_ref: String,
|
||||
|
||||
/// Update label
|
||||
#[arg(long)]
|
||||
label: Option<String>,
|
||||
|
||||
/// Update description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
|
||||
/// Update entrypoint
|
||||
#[arg(long)]
|
||||
entrypoint: Option<String>,
|
||||
|
||||
/// Update runtime ID
|
||||
#[arg(long)]
|
||||
runtime: Option<i64>,
|
||||
},
|
||||
/// Delete an action
|
||||
Delete {
|
||||
/// Action reference (pack.action or ID)
|
||||
action_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short, long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Execute an action
|
||||
Execute {
|
||||
/// Action reference (pack.action or ID)
|
||||
action_ref: String,
|
||||
|
||||
/// Action parameters in key=value format
|
||||
#[arg(long)]
|
||||
param: Vec<String>,
|
||||
|
||||
/// Parameters as JSON string
|
||||
#[arg(long, conflicts_with = "param")]
|
||||
params_json: Option<String>,
|
||||
|
||||
/// Wait for execution to complete
|
||||
#[arg(short, long)]
|
||||
wait: bool,
|
||||
|
||||
/// Timeout in seconds when waiting (default: 300)
|
||||
#[arg(long, default_value = "300", requires = "wait")]
|
||||
timeout: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Action {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
action_ref: String,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
entrypoint: String,
|
||||
runtime: Option<i64>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ActionDetail {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
action_ref: String,
|
||||
pack: i64,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
entrypoint: String,
|
||||
runtime: Option<i64>,
|
||||
param_schema: Option<serde_json::Value>,
|
||||
out_schema: Option<serde_json::Value>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateActionRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
entrypoint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
runtime: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ExecuteActionRequest {
|
||||
action_ref: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Execution {
|
||||
id: i64,
|
||||
action: Option<i64>,
|
||||
action_ref: String,
|
||||
config: Option<serde_json::Value>,
|
||||
parent: Option<i64>,
|
||||
enforcement: Option<i64>,
|
||||
executor: Option<i64>,
|
||||
status: String,
|
||||
result: Option<serde_json::Value>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
pub async fn handle_action_command(
|
||||
profile: &Option<String>,
|
||||
command: ActionCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
ActionCommands::List { pack, name } => {
|
||||
handle_list(pack, name, profile, api_url, output_format).await
|
||||
}
|
||||
ActionCommands::Show { action_ref } => {
|
||||
handle_show(action_ref, profile, api_url, output_format).await
|
||||
}
|
||||
ActionCommands::Update {
|
||||
action_ref,
|
||||
label,
|
||||
description,
|
||||
entrypoint,
|
||||
runtime,
|
||||
} => {
|
||||
handle_update(
|
||||
action_ref,
|
||||
label,
|
||||
description,
|
||||
entrypoint,
|
||||
runtime,
|
||||
profile,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ActionCommands::Delete { action_ref, yes } => {
|
||||
handle_delete(action_ref, yes, profile, api_url, output_format).await
|
||||
}
|
||||
ActionCommands::Execute {
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
wait,
|
||||
timeout,
|
||||
} => {
|
||||
handle_execute(
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
profile,
|
||||
api_url,
|
||||
wait,
|
||||
timeout,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
pack: Option<String>,
|
||||
name: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Use pack-specific endpoint if pack filter is specified
|
||||
let path = if let Some(pack_ref) = pack {
|
||||
format!("/packs/{}/actions", pack_ref)
|
||||
} else {
|
||||
"/actions".to_string()
|
||||
};
|
||||
|
||||
let mut actions: Vec<Action> = client.get(&path).await?;
|
||||
|
||||
// Filter by name if specified (client-side filtering)
|
||||
if let Some(action_name) = name {
|
||||
actions.retain(|a| a.action_ref.contains(&action_name));
|
||||
}
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&actions, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if actions.is_empty() {
|
||||
output::print_info("No actions found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Pack", "Name", "Runner", "Enabled", "Description"],
|
||||
);
|
||||
|
||||
for action in actions {
|
||||
table.add_row(vec![
|
||||
action.id.to_string(),
|
||||
action.pack_ref.clone(),
|
||||
action.action_ref.clone(),
|
||||
action
|
||||
.runtime
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
"✓".to_string(),
|
||||
output::truncate(&action.description, 40),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show(
|
||||
action_ref: String,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/actions/{}", action_ref);
|
||||
let action: ActionDetail = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&action, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Action: {}", action.action_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", action.id.to_string()),
|
||||
("Reference", action.action_ref.clone()),
|
||||
("Pack", action.pack_ref.clone()),
|
||||
("Label", action.label.clone()),
|
||||
("Description", action.description.clone()),
|
||||
("Entry Point", action.entrypoint.clone()),
|
||||
(
|
||||
"Runtime",
|
||||
action
|
||||
.runtime
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Created", output::format_timestamp(&action.created)),
|
||||
("Updated", output::format_timestamp(&action.updated)),
|
||||
]);
|
||||
|
||||
if let Some(params) = action.param_schema {
|
||||
if !params.is_null() {
|
||||
output::print_section("Parameters Schema");
|
||||
println!("{}", serde_json::to_string_pretty(¶ms)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_update(
|
||||
action_ref: String,
|
||||
label: Option<String>,
|
||||
description: Option<String>,
|
||||
entrypoint: Option<String>,
|
||||
runtime: Option<i64>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Check that at least one field is provided
|
||||
if label.is_none() && description.is_none() && entrypoint.is_none() && runtime.is_none() {
|
||||
anyhow::bail!("At least one field must be provided to update");
|
||||
}
|
||||
|
||||
let request = UpdateActionRequest {
|
||||
label,
|
||||
description,
|
||||
entrypoint,
|
||||
runtime,
|
||||
};
|
||||
|
||||
let path = format!("/actions/{}", action_ref);
|
||||
let action: ActionDetail = client.put(&path, &request).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&action, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!(
|
||||
"Action '{}' updated successfully",
|
||||
action.action_ref
|
||||
));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", action.id.to_string()),
|
||||
("Ref", action.action_ref.clone()),
|
||||
("Pack", action.pack_ref.clone()),
|
||||
("Label", action.label.clone()),
|
||||
("Description", action.description.clone()),
|
||||
("Entrypoint", action.entrypoint.clone()),
|
||||
(
|
||||
"Runtime",
|
||||
action
|
||||
.runtime
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Updated", output::format_timestamp(&action.updated)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete(
|
||||
action_ref: String,
|
||||
yes: bool,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Confirm deletion unless --yes is provided
|
||||
if !yes && matches!(output_format, OutputFormat::Table) {
|
||||
let confirm = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to delete action '{}'?",
|
||||
action_ref
|
||||
))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output::print_info("Delete cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let path = format!("/actions/{}", action_ref);
|
||||
client.delete_no_response(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let msg = serde_json::json!({"message": "Action deleted successfully"});
|
||||
output::print_output(&msg, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Action '{}' deleted successfully", action_ref));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_execute(
|
||||
action_ref: String,
|
||||
params: Vec<String>,
|
||||
params_json: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
wait: bool,
|
||||
timeout: u64,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Parse parameters
|
||||
let parameters = if let Some(json_str) = params_json {
|
||||
serde_json::from_str(&json_str)?
|
||||
} else if !params.is_empty() {
|
||||
let mut map = HashMap::new();
|
||||
for p in params {
|
||||
let parts: Vec<&str> = p.splitn(2, '=').collect();
|
||||
if parts.len() != 2 {
|
||||
anyhow::bail!("Invalid parameter format: '{}'. Expected key=value", p);
|
||||
}
|
||||
// Try to parse as JSON value, fall back to string
|
||||
let value: serde_json::Value = serde_json::from_str(parts[1])
|
||||
.unwrap_or_else(|_| serde_json::Value::String(parts[1].to_string()));
|
||||
map.insert(parts[0].to_string(), value);
|
||||
}
|
||||
serde_json::to_value(map)?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
let request = ExecuteActionRequest {
|
||||
action_ref: action_ref.clone(),
|
||||
parameters,
|
||||
};
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!("Executing action: {}", action_ref));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let path = "/executions/execute".to_string();
|
||||
let mut execution: Execution = client.post(&path, &request).await?;
|
||||
|
||||
if wait {
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!(
|
||||
"Waiting for execution {} to complete...",
|
||||
execution.id
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Poll for completion
|
||||
let start = std::time::Instant::now();
|
||||
let timeout_duration = std::time::Duration::from_secs(timeout);
|
||||
|
||||
loop {
|
||||
if start.elapsed() > timeout_duration {
|
||||
anyhow::bail!("Execution timed out after {} seconds", timeout);
|
||||
}
|
||||
|
||||
let exec_path = format!("/executions/{}", execution.id);
|
||||
execution = client.get(&exec_path).await?;
|
||||
|
||||
if execution.status == "succeeded"
|
||||
|| execution.status == "failed"
|
||||
|| execution.status == "canceled"
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&execution, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!(
|
||||
"Execution {} {}",
|
||||
execution.id,
|
||||
if wait { "completed" } else { "started" }
|
||||
));
|
||||
output::print_section("Execution Details");
|
||||
output::print_key_value_table(vec![
|
||||
("Execution ID", execution.id.to_string()),
|
||||
("Action", execution.action_ref.clone()),
|
||||
("Status", output::format_status(&execution.status)),
|
||||
("Created", output::format_timestamp(&execution.created)),
|
||||
("Updated", output::format_timestamp(&execution.updated)),
|
||||
]);
|
||||
|
||||
if let Some(result) = execution.result {
|
||||
if !result.is_null() {
|
||||
output::print_section("Result");
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
213
crates/cli/src/commands/auth.rs
Normal file
213
crates/cli/src/commands/auth.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum AuthCommands {
|
||||
/// Log in to Attune API
|
||||
Login {
|
||||
/// Username or email
|
||||
#[arg(short, long)]
|
||||
username: String,
|
||||
|
||||
/// Password (will prompt if not provided)
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
},
|
||||
/// Log out and clear authentication tokens
|
||||
Logout,
|
||||
/// Show current authentication status
|
||||
Whoami,
|
||||
/// Refresh authentication token
|
||||
Refresh,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LoginRequest {
|
||||
login: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LoginResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
expires_in: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Identity {
|
||||
id: i64,
|
||||
login: String,
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn handle_auth_command(
|
||||
profile: &Option<String>,
|
||||
command: AuthCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
AuthCommands::Login { username, password } => {
|
||||
handle_login(username, password, profile, api_url, output_format).await
|
||||
}
|
||||
AuthCommands::Logout => handle_logout(profile, output_format).await,
|
||||
AuthCommands::Whoami => handle_whoami(profile, api_url, output_format).await,
|
||||
AuthCommands::Refresh => handle_refresh(profile, api_url, output_format).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_login(
|
||||
username: String,
|
||||
password: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
|
||||
// Prompt for password if not provided
|
||||
let password = match password {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let pw = dialoguer::Password::new()
|
||||
.with_prompt("Password")
|
||||
.interact()?;
|
||||
pw
|
||||
}
|
||||
};
|
||||
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let login_req = LoginRequest {
|
||||
login: username,
|
||||
password,
|
||||
};
|
||||
|
||||
let response: LoginResponse = client.post("/auth/login", &login_req).await?;
|
||||
|
||||
// Save tokens to config
|
||||
let mut config = CliConfig::load()?;
|
||||
config.set_auth(
|
||||
response.access_token.clone(),
|
||||
response.refresh_token.clone(),
|
||||
)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&response, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success("Successfully logged in");
|
||||
output::print_info(&format!("Token expires in {} seconds", response.expires_in));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_logout(profile: &Option<String>, output_format: OutputFormat) -> Result<()> {
|
||||
let mut config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
config.clear_auth()?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let msg = serde_json::json!({"message": "Successfully logged out"});
|
||||
output::print_output(&msg, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success("Successfully logged out");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_whoami(
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
|
||||
if config.auth_token().ok().flatten().is_none() {
|
||||
anyhow::bail!("Not logged in. Use 'attune auth login' to authenticate.");
|
||||
}
|
||||
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let identity: Identity = client.get("/auth/me").await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&identity, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section("Current Identity");
|
||||
output::print_key_value_table(vec![
|
||||
("ID", identity.id.to_string()),
|
||||
("Login", identity.login),
|
||||
(
|
||||
"Display Name",
|
||||
identity.display_name.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_refresh(
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
|
||||
// Check if we have a refresh token
|
||||
let refresh_token = config
|
||||
.refresh_token()
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| anyhow::anyhow!("No refresh token found. Please log in again."))?;
|
||||
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest {
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
// Call the refresh endpoint
|
||||
let response: LoginResponse = client
|
||||
.post("/auth/refresh", &RefreshRequest { refresh_token })
|
||||
.await?;
|
||||
|
||||
// Save new tokens to config
|
||||
let mut config = CliConfig::load()?;
|
||||
config.set_auth(
|
||||
response.access_token.clone(),
|
||||
response.refresh_token.clone(),
|
||||
)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&response, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success("Token refreshed successfully");
|
||||
output::print_info(&format!(
|
||||
"New token expires in {} seconds",
|
||||
response.expires_in
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
354
crates/cli/src/commands/config.rs
Normal file
354
crates/cli/src/commands/config.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Subcommand;
|
||||
use colored::Colorize;
|
||||
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ConfigCommands {
|
||||
/// List all configuration values
|
||||
List,
|
||||
/// Get a configuration value
|
||||
Get {
|
||||
/// Configuration key
|
||||
key: String,
|
||||
},
|
||||
/// Set a configuration value
|
||||
Set {
|
||||
/// Configuration key
|
||||
key: String,
|
||||
/// Configuration value
|
||||
value: String,
|
||||
},
|
||||
/// Show the configuration file path
|
||||
Path,
|
||||
/// List all profiles
|
||||
Profiles,
|
||||
/// Show current profile
|
||||
Current,
|
||||
/// Switch to a different profile
|
||||
Use {
|
||||
/// Profile name
|
||||
name: String,
|
||||
},
|
||||
/// Add or update a profile
|
||||
AddProfile {
|
||||
/// Profile name
|
||||
name: String,
|
||||
/// API URL
|
||||
#[arg(short, long)]
|
||||
api_url: String,
|
||||
/// Description
|
||||
#[arg(short, long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
/// Remove a profile
|
||||
RemoveProfile {
|
||||
/// Profile name
|
||||
name: String,
|
||||
},
|
||||
/// Show profile details
|
||||
ShowProfile {
|
||||
/// Profile name
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn handle_config_command(
|
||||
_profile: &Option<String>,
|
||||
command: ConfigCommands,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
ConfigCommands::List => handle_list(output_format).await,
|
||||
ConfigCommands::Get { key } => handle_get(key, output_format).await,
|
||||
ConfigCommands::Set { key, value } => handle_set(key, value, output_format).await,
|
||||
ConfigCommands::Path => handle_path(output_format).await,
|
||||
ConfigCommands::Profiles => handle_profiles(output_format).await,
|
||||
ConfigCommands::Current => handle_current(output_format).await,
|
||||
ConfigCommands::Use { name } => handle_use(name, output_format).await,
|
||||
ConfigCommands::AddProfile {
|
||||
name,
|
||||
api_url,
|
||||
description,
|
||||
} => handle_add_profile(name, api_url, description, output_format).await,
|
||||
ConfigCommands::RemoveProfile { name } => handle_remove_profile(name, output_format).await,
|
||||
ConfigCommands::ShowProfile { name } => handle_show_profile(name, output_format).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(output_format: OutputFormat) -> Result<()> {
|
||||
let config = CliConfig::load()?; // Config commands always use default profile
|
||||
let all_config = config.list_all();
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json => {
|
||||
let map: std::collections::HashMap<String, String> = all_config.into_iter().collect();
|
||||
output::print_output(&map, output_format)?;
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
let map: std::collections::HashMap<String, String> = all_config.into_iter().collect();
|
||||
output::print_output(&map, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section("Configuration");
|
||||
let pairs: Vec<(&str, String)> = all_config
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.clone()))
|
||||
.collect();
|
||||
output::print_key_value_table(pairs);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_get(key: String, output_format: OutputFormat) -> Result<()> {
|
||||
let config = CliConfig::load()?; // Config commands always use default profile
|
||||
let value = config.get_value(&key)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"key": key,
|
||||
"value": value
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!("{}", value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_profiles(output_format: OutputFormat) -> Result<()> {
|
||||
let config = CliConfig::load()?; // Config commands always use default profile
|
||||
let profiles = config.list_profiles();
|
||||
let current = &config.current_profile;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json => {
|
||||
let data: Vec<_> = profiles
|
||||
.iter()
|
||||
.map(|name| {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"current": name == current
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
output::print_output(&data, output_format)?;
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
let data: Vec<_> = profiles
|
||||
.iter()
|
||||
.map(|name| {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"current": name == current
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
output::print_output(&data, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section("Profiles");
|
||||
for name in profiles {
|
||||
if name == *current {
|
||||
println!(" • {} (active)", name.bright_green().bold());
|
||||
} else {
|
||||
println!(" • {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_current(output_format: OutputFormat) -> Result<()> {
|
||||
let config = CliConfig::load()?; // Config commands always use default profile
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"current_profile": config.current_profile
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!("{}", config.current_profile);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_use(name: String, output_format: OutputFormat) -> Result<()> {
|
||||
let mut config = CliConfig::load()?;
|
||||
config.switch_profile(name.clone())?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"current_profile": name,
|
||||
"message": "Switched profile"
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Switched to profile '{}'", name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_add_profile(
|
||||
name: String,
|
||||
api_url: String,
|
||||
description: Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
use crate::config::Profile;
|
||||
|
||||
let mut config = CliConfig::load()?;
|
||||
|
||||
let profile = Profile {
|
||||
api_url: api_url.clone(),
|
||||
auth_token: None,
|
||||
refresh_token: None,
|
||||
output_format: None,
|
||||
description,
|
||||
};
|
||||
|
||||
config.set_profile(name.clone(), profile)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"profile": name,
|
||||
"api_url": api_url,
|
||||
"message": "Profile added"
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Profile '{}' added", name));
|
||||
output::print_info(&format!("API URL: {}", api_url));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_remove_profile(name: String, output_format: OutputFormat) -> Result<()> {
|
||||
let mut config = CliConfig::load()?;
|
||||
config.remove_profile(&name)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"profile": name,
|
||||
"message": "Profile removed"
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Profile '{}' removed", name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show_profile(name: String, output_format: OutputFormat) -> Result<()> {
|
||||
let config = CliConfig::load()?; // Config commands always use default profile
|
||||
let profile = config
|
||||
.get_profile(&name)
|
||||
.context(format!("Profile '{}' not found", name))?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&profile, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Profile: {}", name));
|
||||
let mut pairs = vec![
|
||||
("API URL", profile.api_url.clone()),
|
||||
(
|
||||
"Auth Token",
|
||||
profile
|
||||
.auth_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
"Refresh Token",
|
||||
profile
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(output_format) = &profile.output_format {
|
||||
pairs.push(("Output Format", output_format.clone()));
|
||||
}
|
||||
|
||||
if let Some(description) = &profile.description {
|
||||
pairs.push(("Description", description.clone()));
|
||||
}
|
||||
|
||||
output::print_key_value_table(pairs);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_set(key: String, value: String, output_format: OutputFormat) -> Result<()> {
|
||||
let mut config = CliConfig::load()?;
|
||||
config.set_value(&key, value.clone())?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"key": key,
|
||||
"value": value,
|
||||
"message": "Configuration updated"
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!("Configuration updated: {} = {}", key, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_path(output_format: OutputFormat) -> Result<()> {
|
||||
let path = CliConfig::config_path()?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"path": path.to_string_lossy()
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!("{}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
445
crates/cli/src/commands/execution.rs
Normal file
445
crates/cli/src/commands/execution.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ExecutionCommands {
|
||||
/// List all executions
|
||||
List {
|
||||
/// Filter by pack name
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
|
||||
/// Filter by action name
|
||||
#[arg(short, long)]
|
||||
action: Option<String>,
|
||||
|
||||
/// Filter by status
|
||||
#[arg(short, long)]
|
||||
status: Option<String>,
|
||||
|
||||
/// Search in execution result (case-insensitive)
|
||||
#[arg(short, long)]
|
||||
result: Option<String>,
|
||||
|
||||
/// Limit number of results
|
||||
#[arg(short, long, default_value = "50")]
|
||||
limit: i32,
|
||||
},
|
||||
/// Show details of a specific execution
|
||||
Show {
|
||||
/// Execution ID
|
||||
execution_id: i64,
|
||||
},
|
||||
/// Show execution logs
|
||||
Logs {
|
||||
/// Execution ID
|
||||
execution_id: i64,
|
||||
|
||||
/// Follow log output
|
||||
#[arg(short, long)]
|
||||
follow: bool,
|
||||
},
|
||||
/// Cancel a running execution
|
||||
Cancel {
|
||||
/// Execution ID
|
||||
execution_id: i64,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Get raw execution result
|
||||
Result {
|
||||
/// Execution ID
|
||||
execution_id: i64,
|
||||
|
||||
/// Output format (json or yaml, default: json)
|
||||
#[arg(short = 'f', long, value_enum, default_value = "json")]
|
||||
format: ResultFormat,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
pub enum ResultFormat {
|
||||
Json,
|
||||
Yaml,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Execution {
|
||||
id: i64,
|
||||
action_ref: String,
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
parent: Option<i64>,
|
||||
#[serde(default)]
|
||||
enforcement: Option<i64>,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
created: String,
|
||||
#[serde(default)]
|
||||
updated: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ExecutionDetail {
|
||||
id: i64,
|
||||
#[serde(default)]
|
||||
action: Option<i64>,
|
||||
action_ref: String,
|
||||
#[serde(default)]
|
||||
config: Option<serde_json::Value>,
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
parent: Option<i64>,
|
||||
#[serde(default)]
|
||||
enforcement: Option<i64>,
|
||||
#[serde(default)]
|
||||
executor: Option<i64>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ExecutionLogs {
|
||||
execution_id: i64,
|
||||
logs: Vec<LogEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LogEntry {
|
||||
timestamp: String,
|
||||
level: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub async fn handle_execution_command(
|
||||
profile: &Option<String>,
|
||||
command: ExecutionCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
ExecutionCommands::List {
|
||||
pack,
|
||||
action,
|
||||
status,
|
||||
result,
|
||||
limit,
|
||||
} => {
|
||||
handle_list(
|
||||
profile,
|
||||
pack,
|
||||
action,
|
||||
status,
|
||||
result,
|
||||
limit,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ExecutionCommands::Show { execution_id } => {
|
||||
handle_show(profile, execution_id, api_url, output_format).await
|
||||
}
|
||||
ExecutionCommands::Logs {
|
||||
execution_id,
|
||||
follow,
|
||||
} => handle_logs(profile, execution_id, follow, api_url, output_format).await,
|
||||
ExecutionCommands::Cancel { execution_id, yes } => {
|
||||
handle_cancel(profile, execution_id, yes, api_url, output_format).await
|
||||
}
|
||||
ExecutionCommands::Result {
|
||||
execution_id,
|
||||
format,
|
||||
} => handle_result(profile, execution_id, format, api_url).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
profile: &Option<String>,
|
||||
pack: Option<String>,
|
||||
action: Option<String>,
|
||||
status: Option<String>,
|
||||
result: Option<String>,
|
||||
limit: i32,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let mut query_params = vec![format!("per_page={}", limit)];
|
||||
if let Some(pack_name) = pack {
|
||||
query_params.push(format!("pack_name={}", pack_name));
|
||||
}
|
||||
if let Some(action_name) = action {
|
||||
query_params.push(format!("action_ref={}", action_name));
|
||||
}
|
||||
if let Some(status_filter) = status {
|
||||
query_params.push(format!("status={}", status_filter));
|
||||
}
|
||||
if let Some(result_search) = result {
|
||||
query_params.push(format!(
|
||||
"result_contains={}",
|
||||
urlencoding::encode(&result_search)
|
||||
));
|
||||
}
|
||||
|
||||
let path = format!("/executions?{}", query_params.join("&"));
|
||||
let executions: Vec<Execution> = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&executions, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if executions.is_empty() {
|
||||
output::print_info("No executions found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Action", "Status", "Started", "Duration"],
|
||||
);
|
||||
|
||||
for execution in executions {
|
||||
table.add_row(vec![
|
||||
execution.id.to_string(),
|
||||
execution.action_ref.clone(),
|
||||
output::format_status(&execution.status),
|
||||
output::format_timestamp(&execution.created),
|
||||
"-".to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show(
|
||||
profile: &Option<String>,
|
||||
execution_id: i64,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/executions/{}", execution_id);
|
||||
let execution: ExecutionDetail = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&execution, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Execution: {}", execution.id));
|
||||
|
||||
output::print_key_value_table(vec![
|
||||
("ID", execution.id.to_string()),
|
||||
("Action", execution.action_ref.clone()),
|
||||
("Status", output::format_status(&execution.status)),
|
||||
(
|
||||
"Parent ID",
|
||||
execution
|
||||
.parent
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
(
|
||||
"Enforcement ID",
|
||||
execution
|
||||
.enforcement
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
(
|
||||
"Executor ID",
|
||||
execution
|
||||
.executor
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Created", output::format_timestamp(&execution.created)),
|
||||
("Updated", output::format_timestamp(&execution.updated)),
|
||||
]);
|
||||
|
||||
if let Some(config) = execution.config {
|
||||
if !config.is_null() {
|
||||
output::print_section("Configuration");
|
||||
println!("{}", serde_json::to_string_pretty(&config)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(result) = execution.result {
|
||||
if !result.is_null() {
|
||||
output::print_section("Result");
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_logs(
|
||||
profile: &Option<String>,
|
||||
execution_id: i64,
|
||||
follow: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/executions/{}/logs", execution_id);
|
||||
|
||||
if follow {
|
||||
// Polling implementation for following logs
|
||||
let mut last_count = 0;
|
||||
loop {
|
||||
let logs: ExecutionLogs = client.get(&path).await?;
|
||||
|
||||
// Print new logs only
|
||||
for log in logs.logs.iter().skip(last_count) {
|
||||
match output_format {
|
||||
OutputFormat::Json => {
|
||||
println!("{}", serde_json::to_string(log)?);
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
println!("{}", serde_yaml_ng::to_string(log)?);
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!(
|
||||
"[{}] [{}] {}",
|
||||
output::format_timestamp(&log.timestamp),
|
||||
log.level.to_uppercase(),
|
||||
log.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_count = logs.logs.len();
|
||||
|
||||
// Check if execution is complete
|
||||
let exec_path = format!("/executions/{}", execution_id);
|
||||
let execution: ExecutionDetail = client.get(&exec_path).await?;
|
||||
let status_lower = execution.status.to_lowercase();
|
||||
if status_lower == "succeeded" || status_lower == "failed" || status_lower == "canceled"
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
} else {
|
||||
let logs: ExecutionLogs = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&logs, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if logs.logs.is_empty() {
|
||||
output::print_info("No logs available");
|
||||
} else {
|
||||
for log in logs.logs {
|
||||
println!(
|
||||
"[{}] [{}] {}",
|
||||
output::format_timestamp(&log.timestamp),
|
||||
log.level.to_uppercase(),
|
||||
log.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_result(
|
||||
profile: &Option<String>,
|
||||
execution_id: i64,
|
||||
format: ResultFormat,
|
||||
api_url: &Option<String>,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/executions/{}", execution_id);
|
||||
let execution: ExecutionDetail = client.get(&path).await?;
|
||||
|
||||
// Check if execution has a result
|
||||
if let Some(result) = execution.result {
|
||||
// Output raw result in requested format
|
||||
match format {
|
||||
ResultFormat::Json => {
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
}
|
||||
ResultFormat::Yaml => {
|
||||
println!("{}", serde_yaml_ng::to_string(&result)?);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!("Execution {} has no result yet", execution_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_cancel(
|
||||
profile: &Option<String>,
|
||||
execution_id: i64,
|
||||
yes: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Confirm cancellation unless --yes is provided
|
||||
if !yes && matches!(output_format, OutputFormat::Table) {
|
||||
let confirm = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to cancel execution {}?",
|
||||
execution_id
|
||||
))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output::print_info("Cancellation aborted");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let path = format!("/executions/{}/cancel", execution_id);
|
||||
let execution: ExecutionDetail = client.post(&path, &serde_json::json!({})).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&execution, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Execution {} cancelled", execution_id));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
9
crates/cli/src/commands/mod.rs
Normal file
9
crates/cli/src/commands/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod action;
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod execution;
|
||||
pub mod pack;
|
||||
pub mod pack_index;
|
||||
pub mod rule;
|
||||
pub mod sensor;
|
||||
pub mod trigger;
|
||||
1427
crates/cli/src/commands/pack.rs
Normal file
1427
crates/cli/src/commands/pack.rs
Normal file
File diff suppressed because it is too large
Load Diff
387
crates/cli/src/commands/pack_index.rs
Normal file
387
crates/cli/src/commands/pack_index.rs
Normal file
@@ -0,0 +1,387 @@
|
||||
//! Pack registry index management utilities
|
||||
|
||||
use crate::output::{self, OutputFormat};
|
||||
use anyhow::Result;
|
||||
use attune_common::pack_registry::calculate_directory_checksum;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Update a registry index file with a new pack entry
|
||||
pub async fn handle_index_update(
|
||||
index_path: String,
|
||||
pack_path: String,
|
||||
git_url: Option<String>,
|
||||
git_ref: Option<String>,
|
||||
archive_url: Option<String>,
|
||||
update: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
// Load existing index
|
||||
let index_file_path = Path::new(&index_path);
|
||||
if !index_file_path.exists() {
|
||||
return Err(anyhow::anyhow!("Index file not found: {}", index_path));
|
||||
}
|
||||
|
||||
let index_content = fs::read_to_string(index_file_path)?;
|
||||
let mut index: JsonValue = serde_json::from_str(&index_content)?;
|
||||
|
||||
// Get packs array (or create it)
|
||||
let packs = index
|
||||
.get_mut("packs")
|
||||
.and_then(|p| p.as_array_mut())
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid index format: missing 'packs' array"))?;
|
||||
|
||||
// Load pack.yaml from the pack directory
|
||||
let pack_dir = Path::new(&pack_path);
|
||||
if !pack_dir.exists() || !pack_dir.is_dir() {
|
||||
return Err(anyhow::anyhow!("Pack directory not found: {}", pack_path));
|
||||
}
|
||||
|
||||
let pack_yaml_path = pack_dir.join("pack.yaml");
|
||||
if !pack_yaml_path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"pack.yaml not found in directory: {}",
|
||||
pack_path
|
||||
));
|
||||
}
|
||||
|
||||
let pack_yaml_content = fs::read_to_string(&pack_yaml_path)?;
|
||||
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
|
||||
|
||||
// Extract pack metadata
|
||||
let pack_ref = pack_yaml
|
||||
.get("ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'ref' field in pack.yaml"))?;
|
||||
|
||||
let version = pack_yaml
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'version' field in pack.yaml"))?;
|
||||
|
||||
// Check if pack already exists in index
|
||||
let existing_index = packs
|
||||
.iter()
|
||||
.position(|p| p.get("ref").and_then(|r| r.as_str()) == Some(pack_ref));
|
||||
|
||||
if let Some(_idx) = existing_index {
|
||||
if !update {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Pack '{}' already exists in index. Use --update to replace it.",
|
||||
pack_ref
|
||||
));
|
||||
}
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!("Updating existing entry for '{}'", pack_ref));
|
||||
}
|
||||
} else {
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!("Adding new entry for '{}'", pack_ref));
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate checksum
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info("Calculating checksum...");
|
||||
}
|
||||
let checksum = calculate_directory_checksum(pack_dir)?;
|
||||
|
||||
// Build install sources
|
||||
let mut install_sources = Vec::new();
|
||||
|
||||
if let Some(ref git) = git_url {
|
||||
let default_ref = format!("v{}", version);
|
||||
let ref_value = git_ref.as_ref().map(|s| s.as_str()).unwrap_or(&default_ref);
|
||||
install_sources.push(serde_json::json!({
|
||||
"type": "git",
|
||||
"url": git,
|
||||
"ref": ref_value,
|
||||
"checksum": format!("sha256:{}", checksum)
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(ref archive) = archive_url {
|
||||
install_sources.push(serde_json::json!({
|
||||
"type": "archive",
|
||||
"url": archive,
|
||||
"checksum": format!("sha256:{}", checksum)
|
||||
}));
|
||||
}
|
||||
|
||||
// Extract other metadata
|
||||
let label = pack_yaml
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(pack_ref);
|
||||
|
||||
let description = pack_yaml
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let author = pack_yaml
|
||||
.get("author")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown");
|
||||
|
||||
let license = pack_yaml
|
||||
.get("license")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Apache-2.0");
|
||||
|
||||
let email = pack_yaml.get("email").and_then(|v| v.as_str());
|
||||
let homepage = pack_yaml.get("homepage").and_then(|v| v.as_str());
|
||||
let repository = pack_yaml.get("repository").and_then(|v| v.as_str());
|
||||
|
||||
let keywords: Vec<String> = pack_yaml
|
||||
.get("keywords")
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let runtime_deps: Vec<String> = pack_yaml
|
||||
.get("dependencies")
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Count components
|
||||
let actions_count = pack_yaml["actions"]
|
||||
.as_mapping()
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
let sensors_count = pack_yaml["sensors"]
|
||||
.as_mapping()
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
let triggers_count = pack_yaml["triggers"]
|
||||
.as_mapping()
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Build index entry
|
||||
let mut index_entry = serde_json::json!({
|
||||
"ref": pack_ref,
|
||||
"label": label,
|
||||
"description": description,
|
||||
"version": version,
|
||||
"author": author,
|
||||
"license": license,
|
||||
"keywords": keywords,
|
||||
"runtime_deps": runtime_deps,
|
||||
"install_sources": install_sources,
|
||||
"contents": {
|
||||
"actions": actions_count,
|
||||
"sensors": sensors_count,
|
||||
"triggers": triggers_count,
|
||||
"rules": 0,
|
||||
"workflows": 0
|
||||
}
|
||||
});
|
||||
|
||||
// Add optional fields
|
||||
if let Some(e) = email {
|
||||
index_entry["email"] = JsonValue::String(e.to_string());
|
||||
}
|
||||
if let Some(h) = homepage {
|
||||
index_entry["homepage"] = JsonValue::String(h.to_string());
|
||||
}
|
||||
if let Some(r) = repository {
|
||||
index_entry["repository"] = JsonValue::String(r.to_string());
|
||||
}
|
||||
|
||||
// Update or add entry
|
||||
if let Some(idx) = existing_index {
|
||||
packs[idx] = index_entry;
|
||||
} else {
|
||||
packs.push(index_entry);
|
||||
}
|
||||
|
||||
// Write updated index back to file
|
||||
let updated_content = serde_json::to_string_pretty(&index)?;
|
||||
fs::write(index_file_path, updated_content)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("✓ Index updated successfully: {}", index_path));
|
||||
output::print_info(&format!(" Pack: {} v{}", pack_ref, version));
|
||||
output::print_info(&format!(" Checksum: sha256:{}", checksum));
|
||||
}
|
||||
OutputFormat::Json => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"index_file": index_path,
|
||||
"pack_ref": pack_ref,
|
||||
"version": version,
|
||||
"checksum": format!("sha256:{}", checksum),
|
||||
"action": if existing_index.is_some() { "updated" } else { "added" }
|
||||
});
|
||||
output::print_output(&response, OutputFormat::Json)?;
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"index_file": index_path,
|
||||
"pack_ref": pack_ref,
|
||||
"version": version,
|
||||
"checksum": format!("sha256:{}", checksum),
|
||||
"action": if existing_index.is_some() { "updated" } else { "added" }
|
||||
});
|
||||
output::print_output(&response, OutputFormat::Yaml)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge multiple registry index files into one
|
||||
pub async fn handle_index_merge(
|
||||
output_path: String,
|
||||
input_paths: Vec<String>,
|
||||
force: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
// Check if output file exists
|
||||
let output_file_path = Path::new(&output_path);
|
||||
if output_file_path.exists() && !force {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Output file already exists: {}. Use --force to overwrite.",
|
||||
output_path
|
||||
));
|
||||
}
|
||||
|
||||
// Track all packs by ref (for deduplication)
|
||||
let mut packs_map: HashMap<String, JsonValue> = HashMap::new();
|
||||
let mut total_loaded = 0;
|
||||
let mut duplicates_resolved = 0;
|
||||
|
||||
// Load and merge all input files
|
||||
for input_path in &input_paths {
|
||||
let input_file_path = Path::new(input_path);
|
||||
if !input_file_path.exists() {
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_warning(&format!("Skipping missing file: {}", input_path));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!("Loading: {}", input_path));
|
||||
}
|
||||
|
||||
let index_content = fs::read_to_string(input_file_path)?;
|
||||
let index: JsonValue = serde_json::from_str(&index_content)?;
|
||||
|
||||
let packs = index
|
||||
.get("packs")
|
||||
.and_then(|p| p.as_array())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid index format in {}: missing 'packs' array",
|
||||
input_path
|
||||
)
|
||||
})?;
|
||||
|
||||
for pack in packs {
|
||||
let pack_ref = pack.get("ref").and_then(|r| r.as_str()).ok_or_else(|| {
|
||||
anyhow::anyhow!("Pack entry missing 'ref' field in {}", input_path)
|
||||
})?;
|
||||
|
||||
if packs_map.contains_key(pack_ref) {
|
||||
// Check versions and keep the latest
|
||||
let existing_version = packs_map[pack_ref]
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0");
|
||||
|
||||
let new_version = pack
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0");
|
||||
|
||||
// Simple string comparison (could use semver crate for proper comparison)
|
||||
if new_version > existing_version {
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!(
|
||||
" Updating '{}' from {} to {}",
|
||||
pack_ref, existing_version, new_version
|
||||
));
|
||||
}
|
||||
packs_map.insert(pack_ref.to_string(), pack.clone());
|
||||
} else {
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!(
|
||||
" Keeping '{}' at {} (newer than {})",
|
||||
pack_ref, existing_version, new_version
|
||||
));
|
||||
}
|
||||
}
|
||||
duplicates_resolved += 1;
|
||||
} else {
|
||||
packs_map.insert(pack_ref.to_string(), pack.clone());
|
||||
}
|
||||
total_loaded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Build merged index
|
||||
let packs: Vec<JsonValue> = packs_map.into_values().collect();
|
||||
let merged_index = serde_json::json!({
|
||||
"version": "1.0",
|
||||
"generated_at": chrono::Utc::now().to_rfc3339(),
|
||||
"packs": packs
|
||||
});
|
||||
|
||||
// Write merged index
|
||||
let merged_content = serde_json::to_string_pretty(&merged_index)?;
|
||||
fs::write(output_file_path, merged_content)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!(
|
||||
"✓ Merged {} index files into {}",
|
||||
input_paths.len(),
|
||||
output_path
|
||||
));
|
||||
output::print_info(&format!(" Total packs loaded: {}", total_loaded));
|
||||
output::print_info(&format!(" Unique packs: {}", packs.len()));
|
||||
if duplicates_resolved > 0 {
|
||||
output::print_info(&format!(" Duplicates resolved: {}", duplicates_resolved));
|
||||
}
|
||||
}
|
||||
OutputFormat::Json => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"output_file": output_path,
|
||||
"sources_count": input_paths.len(),
|
||||
"total_loaded": total_loaded,
|
||||
"unique_packs": packs.len(),
|
||||
"duplicates_resolved": duplicates_resolved
|
||||
});
|
||||
output::print_output(&response, OutputFormat::Json)?;
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"output_file": output_path,
|
||||
"sources_count": input_paths.len(),
|
||||
"total_loaded": total_loaded,
|
||||
"unique_packs": packs.len(),
|
||||
"duplicates_resolved": duplicates_resolved
|
||||
});
|
||||
output::print_output(&response, OutputFormat::Yaml)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
567
crates/cli/src/commands/rule.rs
Normal file
567
crates/cli/src/commands/rule.rs
Normal file
@@ -0,0 +1,567 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum RuleCommands {
|
||||
/// List all rules
|
||||
List {
|
||||
/// Filter by pack name
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
|
||||
/// Filter by enabled status
|
||||
#[arg(short, long)]
|
||||
enabled: Option<bool>,
|
||||
},
|
||||
/// Show details of a specific rule
|
||||
Show {
|
||||
/// Rule reference (pack.rule or ID)
|
||||
rule_ref: String,
|
||||
},
|
||||
/// Update a rule
|
||||
Update {
|
||||
/// Rule reference (pack.rule or ID)
|
||||
rule_ref: String,
|
||||
|
||||
/// Update label
|
||||
#[arg(long)]
|
||||
label: Option<String>,
|
||||
|
||||
/// Update description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
|
||||
/// Update conditions as JSON string
|
||||
#[arg(long)]
|
||||
conditions: Option<String>,
|
||||
|
||||
/// Update action parameters as JSON string
|
||||
#[arg(long)]
|
||||
action_params: Option<String>,
|
||||
|
||||
/// Update trigger parameters as JSON string
|
||||
#[arg(long)]
|
||||
trigger_params: Option<String>,
|
||||
|
||||
/// Update enabled status
|
||||
#[arg(long)]
|
||||
enabled: Option<bool>,
|
||||
},
|
||||
/// Enable a rule
|
||||
Enable {
|
||||
/// Rule reference (pack.rule or ID)
|
||||
rule_ref: String,
|
||||
},
|
||||
/// Disable a rule
|
||||
Disable {
|
||||
/// Rule reference (pack.rule or ID)
|
||||
rule_ref: String,
|
||||
},
|
||||
/// Create a new rule
|
||||
Create {
|
||||
/// Rule name
|
||||
#[arg(short, long)]
|
||||
name: String,
|
||||
|
||||
/// Pack ID or name
|
||||
#[arg(short, long)]
|
||||
pack: String,
|
||||
|
||||
/// Trigger reference
|
||||
#[arg(short, long)]
|
||||
trigger: String,
|
||||
|
||||
/// Action reference
|
||||
#[arg(short, long)]
|
||||
action: String,
|
||||
|
||||
/// Rule description
|
||||
#[arg(short, long)]
|
||||
description: Option<String>,
|
||||
|
||||
/// Rule criteria as JSON string
|
||||
#[arg(long)]
|
||||
criteria: Option<String>,
|
||||
|
||||
/// Enable the rule immediately
|
||||
#[arg(long)]
|
||||
enabled: bool,
|
||||
},
|
||||
/// Delete a rule
|
||||
Delete {
|
||||
/// Rule reference (pack.rule or ID)
|
||||
rule_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Rule {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
rule_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
trigger: Option<i64>,
|
||||
trigger_ref: String,
|
||||
#[serde(default)]
|
||||
action: Option<i64>,
|
||||
action_ref: String,
|
||||
enabled: bool,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct RuleDetail {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
rule_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
trigger: Option<i64>,
|
||||
trigger_ref: String,
|
||||
#[serde(default)]
|
||||
action: Option<i64>,
|
||||
action_ref: String,
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
conditions: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
action_params: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
trigger_params: Option<serde_json::Value>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateRuleRequest {
|
||||
name: String,
|
||||
pack_id: String,
|
||||
trigger_id: String,
|
||||
action_id: String,
|
||||
description: Option<String>,
|
||||
criteria: Option<serde_json::Value>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateRuleRequest {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
pub async fn handle_rule_command(
|
||||
profile: &Option<String>,
|
||||
command: RuleCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
RuleCommands::List { pack, enabled } => {
|
||||
handle_list(profile, pack, enabled, api_url, output_format).await
|
||||
}
|
||||
RuleCommands::Show { rule_ref } => {
|
||||
handle_show(profile, rule_ref, api_url, output_format).await
|
||||
}
|
||||
RuleCommands::Update {
|
||||
rule_ref,
|
||||
label,
|
||||
description,
|
||||
conditions,
|
||||
action_params,
|
||||
trigger_params,
|
||||
enabled,
|
||||
} => {
|
||||
handle_update(
|
||||
profile,
|
||||
rule_ref,
|
||||
label,
|
||||
description,
|
||||
conditions,
|
||||
action_params,
|
||||
trigger_params,
|
||||
enabled,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
RuleCommands::Enable { rule_ref } => {
|
||||
handle_toggle(profile, rule_ref, true, api_url, output_format).await
|
||||
}
|
||||
RuleCommands::Disable { rule_ref } => {
|
||||
handle_toggle(profile, rule_ref, false, api_url, output_format).await
|
||||
}
|
||||
RuleCommands::Create {
|
||||
name,
|
||||
pack,
|
||||
trigger,
|
||||
action,
|
||||
description,
|
||||
criteria,
|
||||
enabled,
|
||||
} => {
|
||||
handle_create(
|
||||
profile,
|
||||
name,
|
||||
pack,
|
||||
trigger,
|
||||
action,
|
||||
description,
|
||||
criteria,
|
||||
enabled,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
RuleCommands::Delete { rule_ref, yes } => {
|
||||
handle_delete(profile, rule_ref, yes, api_url, output_format).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
profile: &Option<String>,
|
||||
pack: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let mut query_params = Vec::new();
|
||||
if let Some(pack_name) = pack {
|
||||
query_params.push(format!("pack={}", pack_name));
|
||||
}
|
||||
if let Some(is_enabled) = enabled {
|
||||
query_params.push(format!("enabled={}", is_enabled));
|
||||
}
|
||||
|
||||
let path = if query_params.is_empty() {
|
||||
"/rules".to_string()
|
||||
} else {
|
||||
format!("/rules?{}", query_params.join("&"))
|
||||
};
|
||||
|
||||
let rules: Vec<Rule> = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&rules, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if rules.is_empty() {
|
||||
output::print_info("No rules found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Pack", "Name", "Trigger", "Action", "Enabled"],
|
||||
);
|
||||
|
||||
for rule in rules {
|
||||
table.add_row(vec![
|
||||
rule.id.to_string(),
|
||||
rule.pack_ref.clone(),
|
||||
rule.label.clone(),
|
||||
rule.trigger_ref.clone(),
|
||||
rule.action_ref.clone(),
|
||||
output::format_bool(rule.enabled),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show(
|
||||
profile: &Option<String>,
|
||||
rule_ref: String,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/rules/{}", rule_ref);
|
||||
let rule: RuleDetail = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&rule, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Rule: {}", rule.rule_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", rule.id.to_string()),
|
||||
("Ref", rule.rule_ref.clone()),
|
||||
("Pack", rule.pack_ref.clone()),
|
||||
("Label", rule.label.clone()),
|
||||
("Description", rule.description.clone()),
|
||||
("Trigger", rule.trigger_ref.clone()),
|
||||
("Action", rule.action_ref.clone()),
|
||||
("Enabled", output::format_bool(rule.enabled)),
|
||||
("Created", output::format_timestamp(&rule.created)),
|
||||
("Updated", output::format_timestamp(&rule.updated)),
|
||||
]);
|
||||
|
||||
if let Some(conditions) = rule.conditions {
|
||||
if !conditions.is_null() {
|
||||
output::print_section("Conditions");
|
||||
println!("{}", serde_json::to_string_pretty(&conditions)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(action_params) = rule.action_params {
|
||||
if !action_params.is_null() {
|
||||
output::print_section("Action Parameters");
|
||||
println!("{}", serde_json::to_string_pretty(&action_params)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(trigger_params) = rule.trigger_params {
|
||||
if !trigger_params.is_null() {
|
||||
output::print_section("Trigger Parameters");
|
||||
println!("{}", serde_json::to_string_pretty(&trigger_params)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_update(
|
||||
profile: &Option<String>,
|
||||
rule_ref: String,
|
||||
label: Option<String>,
|
||||
description: Option<String>,
|
||||
conditions: Option<String>,
|
||||
action_params: Option<String>,
|
||||
trigger_params: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Check that at least one field is provided
|
||||
if label.is_none()
|
||||
&& description.is_none()
|
||||
&& conditions.is_none()
|
||||
&& action_params.is_none()
|
||||
&& trigger_params.is_none()
|
||||
&& enabled.is_none()
|
||||
{
|
||||
anyhow::bail!("At least one field must be provided to update");
|
||||
}
|
||||
|
||||
// Parse JSON fields
|
||||
let conditions_json = if let Some(cond) = conditions {
|
||||
Some(serde_json::from_str(&cond)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let action_params_json = if let Some(params) = action_params {
|
||||
Some(serde_json::from_str(¶ms)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let trigger_params_json = if let Some(params) = trigger_params {
|
||||
Some(serde_json::from_str(¶ms)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UpdateRuleRequestCli {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
conditions: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
action_params: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
trigger_params: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
let request = UpdateRuleRequestCli {
|
||||
label,
|
||||
description,
|
||||
conditions: conditions_json,
|
||||
action_params: action_params_json,
|
||||
trigger_params: trigger_params_json,
|
||||
enabled,
|
||||
};
|
||||
|
||||
let path = format!("/rules/{}", rule_ref);
|
||||
let rule: RuleDetail = client.put(&path, &request).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&rule, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Rule '{}' updated successfully", rule.rule_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", rule.id.to_string()),
|
||||
("Ref", rule.rule_ref.clone()),
|
||||
("Pack", rule.pack_ref.clone()),
|
||||
("Label", rule.label.clone()),
|
||||
("Description", rule.description.clone()),
|
||||
("Trigger", rule.trigger_ref.clone()),
|
||||
("Action", rule.action_ref.clone()),
|
||||
("Enabled", output::format_bool(rule.enabled)),
|
||||
("Updated", output::format_timestamp(&rule.updated)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_toggle(
|
||||
profile: &Option<String>,
|
||||
rule_ref: String,
|
||||
enabled: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let request = UpdateRuleRequest { enabled };
|
||||
let path = format!("/rules/{}", rule_ref);
|
||||
let rule: Rule = client.patch(&path, &request).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&rule, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
let action = if enabled { "enabled" } else { "disabled" };
|
||||
output::print_success(&format!("Rule '{}' {}", rule.rule_ref, action));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_create(
|
||||
profile: &Option<String>,
|
||||
name: String,
|
||||
pack: String,
|
||||
trigger: String,
|
||||
action: String,
|
||||
description: Option<String>,
|
||||
criteria: Option<String>,
|
||||
enabled: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let criteria_value = if let Some(criteria_str) = criteria {
|
||||
Some(serde_json::from_str(&criteria_str)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = CreateRuleRequest {
|
||||
name: name.clone(),
|
||||
pack_id: pack,
|
||||
trigger_id: trigger,
|
||||
action_id: action,
|
||||
description,
|
||||
criteria: criteria_value,
|
||||
enabled,
|
||||
};
|
||||
|
||||
let rule: Rule = client.post("/rules", &request).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&rule, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Rule '{}' created successfully", rule.rule_ref));
|
||||
output::print_info(&format!("ID: {}", rule.id));
|
||||
output::print_info(&format!("Enabled: {}", rule.enabled));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete(
|
||||
profile: &Option<String>,
|
||||
rule_ref: String,
|
||||
yes: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Confirm deletion unless --yes is provided
|
||||
if !yes && matches!(output_format, OutputFormat::Table) {
|
||||
let confirm = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to delete rule '{}'?",
|
||||
rule_ref
|
||||
))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output::print_info("Deletion cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let path = format!("/rules/{}", rule_ref);
|
||||
client.delete_no_response(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let msg = serde_json::json!({"message": "Rule deleted successfully"});
|
||||
output::print_output(&msg, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Rule '{}' deleted successfully", rule_ref));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
187
crates/cli/src/commands/sensor.rs
Normal file
187
crates/cli/src/commands/sensor.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SensorCommands {
|
||||
/// List all sensors
|
||||
List {
|
||||
/// Filter by pack name
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
},
|
||||
/// Show details of a specific sensor
|
||||
Show {
|
||||
/// Sensor reference (pack.sensor or ID)
|
||||
sensor_ref: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Sensor {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
sensor_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
#[serde(default)]
|
||||
pack_ref: Option<String>,
|
||||
label: String,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
trigger_types: Vec<String>,
|
||||
enabled: bool,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SensorDetail {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
sensor_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
#[serde(default)]
|
||||
pack_ref: Option<String>,
|
||||
label: String,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
trigger_types: Vec<String>,
|
||||
#[serde(default)]
|
||||
entry_point: Option<String>,
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
poll_interval: Option<i32>,
|
||||
#[serde(default)]
|
||||
metadata: Option<serde_json::Value>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
pub async fn handle_sensor_command(
|
||||
profile: &Option<String>,
|
||||
command: SensorCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
SensorCommands::List { pack } => handle_list(pack, profile, api_url, output_format).await,
|
||||
SensorCommands::Show { sensor_ref } => {
|
||||
handle_show(sensor_ref, profile, api_url, output_format).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
pack: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = if let Some(pack_name) = pack {
|
||||
format!("/sensors?pack={}", pack_name)
|
||||
} else {
|
||||
"/sensors".to_string()
|
||||
};
|
||||
|
||||
let sensors: Vec<Sensor> = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&sensors, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if sensors.is_empty() {
|
||||
output::print_info("No sensors found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Pack", "Name", "Trigger", "Enabled", "Description"],
|
||||
);
|
||||
|
||||
for sensor in sensors {
|
||||
table.add_row(vec![
|
||||
sensor.id.to_string(),
|
||||
sensor.pack_ref.as_deref().unwrap_or("").to_string(),
|
||||
sensor.label.clone(),
|
||||
sensor.trigger_types.join(", "),
|
||||
output::format_bool(sensor.enabled),
|
||||
output::truncate(&sensor.description.unwrap_or_default(), 50),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show(
|
||||
sensor_ref: String,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/sensors/{}", sensor_ref);
|
||||
let sensor: SensorDetail = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&sensor, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Sensor: {}", sensor.sensor_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", sensor.id.to_string()),
|
||||
("Ref", sensor.sensor_ref.clone()),
|
||||
(
|
||||
"Pack",
|
||||
sensor.pack_ref.as_deref().unwrap_or("None").to_string(),
|
||||
),
|
||||
("Label", sensor.label.clone()),
|
||||
(
|
||||
"Description",
|
||||
sensor.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Trigger Types", sensor.trigger_types.join(", ")),
|
||||
(
|
||||
"Entry Point",
|
||||
sensor.entry_point.as_deref().unwrap_or("N/A").to_string(),
|
||||
),
|
||||
("Enabled", output::format_bool(sensor.enabled)),
|
||||
(
|
||||
"Poll Interval",
|
||||
sensor
|
||||
.poll_interval
|
||||
.map(|i| format!("{}s", i))
|
||||
.unwrap_or_else(|| "N/A".to_string()),
|
||||
),
|
||||
("Created", output::format_timestamp(&sensor.created)),
|
||||
("Updated", output::format_timestamp(&sensor.updated)),
|
||||
]);
|
||||
|
||||
if let Some(metadata) = sensor.metadata {
|
||||
if !metadata.is_null() {
|
||||
output::print_section("Metadata");
|
||||
println!("{}", serde_json::to_string_pretty(&metadata)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
346
crates/cli/src/commands/trigger.rs
Normal file
346
crates/cli/src/commands/trigger.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum TriggerCommands {
|
||||
/// List all triggers
|
||||
List {
|
||||
/// Filter by pack name
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
},
|
||||
/// Show details of a specific trigger
|
||||
Show {
|
||||
/// Trigger reference (pack.trigger or ID)
|
||||
trigger_ref: String,
|
||||
},
|
||||
/// Update a trigger
|
||||
Update {
|
||||
/// Trigger reference (pack.trigger or ID)
|
||||
trigger_ref: String,
|
||||
|
||||
/// Update label
|
||||
#[arg(long)]
|
||||
label: Option<String>,
|
||||
|
||||
/// Update description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
|
||||
/// Update enabled status
|
||||
#[arg(long)]
|
||||
enabled: Option<bool>,
|
||||
},
|
||||
/// Delete a trigger
|
||||
Delete {
|
||||
/// Trigger reference (pack.trigger or ID)
|
||||
trigger_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short, long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Trigger {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
trigger_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
#[serde(default)]
|
||||
pack_ref: Option<String>,
|
||||
label: String,
|
||||
description: Option<String>,
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
param_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
out_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
webhook_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
webhook_key: Option<String>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TriggerDetail {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
trigger_ref: String,
|
||||
#[serde(default)]
|
||||
pack: Option<i64>,
|
||||
#[serde(default)]
|
||||
pack_ref: Option<String>,
|
||||
label: String,
|
||||
description: Option<String>,
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
param_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
out_schema: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
webhook_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
webhook_key: Option<String>,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
pub async fn handle_trigger_command(
|
||||
profile: &Option<String>,
|
||||
command: TriggerCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
TriggerCommands::List { pack } => handle_list(pack, profile, api_url, output_format).await,
|
||||
TriggerCommands::Show { trigger_ref } => {
|
||||
handle_show(trigger_ref, profile, api_url, output_format).await
|
||||
}
|
||||
TriggerCommands::Update {
|
||||
trigger_ref,
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
} => {
|
||||
handle_update(
|
||||
trigger_ref,
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
profile,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
TriggerCommands::Delete { trigger_ref, yes } => {
|
||||
handle_delete(trigger_ref, yes, profile, api_url, output_format).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
pack: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = if let Some(pack_name) = pack {
|
||||
format!("/triggers?pack={}", pack_name)
|
||||
} else {
|
||||
"/triggers".to_string()
|
||||
};
|
||||
|
||||
let triggers: Vec<Trigger> = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&triggers, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if triggers.is_empty() {
|
||||
output::print_info("No triggers found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(&mut table, vec!["ID", "Pack", "Name", "Description"]);
|
||||
|
||||
for trigger in triggers {
|
||||
table.add_row(vec![
|
||||
trigger.id.to_string(),
|
||||
trigger.pack_ref.as_deref().unwrap_or("").to_string(),
|
||||
trigger.label.clone(),
|
||||
output::truncate(&trigger.description.unwrap_or_default(), 50),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_show(
|
||||
trigger_ref: String,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/triggers/{}", trigger_ref);
|
||||
let trigger: TriggerDetail = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&trigger, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Trigger: {}", trigger.trigger_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", trigger.id.to_string()),
|
||||
("Ref", trigger.trigger_ref.clone()),
|
||||
(
|
||||
"Pack",
|
||||
trigger.pack_ref.as_deref().unwrap_or("None").to_string(),
|
||||
),
|
||||
("Label", trigger.label.clone()),
|
||||
(
|
||||
"Description",
|
||||
trigger.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Enabled", output::format_bool(trigger.enabled)),
|
||||
(
|
||||
"Webhook Enabled",
|
||||
output::format_bool(trigger.webhook_enabled.unwrap_or(false)),
|
||||
),
|
||||
("Created", output::format_timestamp(&trigger.created)),
|
||||
("Updated", output::format_timestamp(&trigger.updated)),
|
||||
]);
|
||||
|
||||
if let Some(webhook_key) = &trigger.webhook_key {
|
||||
output::print_section("Webhook");
|
||||
output::print_info(&format!("Key: {}", webhook_key));
|
||||
}
|
||||
|
||||
if let Some(param_schema) = &trigger.param_schema {
|
||||
if !param_schema.is_null() {
|
||||
output::print_section("Parameter Schema");
|
||||
println!("{}", serde_json::to_string_pretty(param_schema)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(out_schema) = &trigger.out_schema {
|
||||
if !out_schema.is_null() {
|
||||
output::print_section("Output Schema");
|
||||
println!("{}", serde_json::to_string_pretty(out_schema)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_update(
|
||||
trigger_ref: String,
|
||||
label: Option<String>,
|
||||
description: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Check that at least one field is provided
|
||||
if label.is_none() && description.is_none() && enabled.is_none() {
|
||||
anyhow::bail!("At least one field must be provided to update");
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UpdateTriggerRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
let request = UpdateTriggerRequest {
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
};
|
||||
|
||||
let path = format!("/triggers/{}", trigger_ref);
|
||||
let trigger: TriggerDetail = client.put(&path, &request).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&trigger, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!(
|
||||
"Trigger '{}' updated successfully",
|
||||
trigger.trigger_ref
|
||||
));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", trigger.id.to_string()),
|
||||
("Ref", trigger.trigger_ref.clone()),
|
||||
(
|
||||
"Pack",
|
||||
trigger.pack_ref.as_deref().unwrap_or("None").to_string(),
|
||||
),
|
||||
("Label", trigger.label.clone()),
|
||||
(
|
||||
"Description",
|
||||
trigger.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Enabled", output::format_bool(trigger.enabled)),
|
||||
("Updated", output::format_timestamp(&trigger.updated)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete(
|
||||
trigger_ref: String,
|
||||
yes: bool,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Confirm deletion unless --yes is provided
|
||||
if !yes && matches!(output_format, OutputFormat::Table) {
|
||||
let confirm = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to delete trigger '{}'?",
|
||||
trigger_ref
|
||||
))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output::print_info("Delete cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let path = format!("/triggers/{}", trigger_ref);
|
||||
client.delete_no_response(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let msg = serde_json::json!({"message": "Trigger deleted successfully"});
|
||||
output::print_output(&msg, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Trigger '{}' deleted successfully", trigger_ref));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
459
crates/cli/src/config.rs
Normal file
459
crates/cli/src/config.rs
Normal file
@@ -0,0 +1,459 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// CLI configuration stored in user's home directory
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CliConfig {
|
||||
/// Current active profile name
|
||||
#[serde(default = "default_profile_name")]
|
||||
pub current_profile: String,
|
||||
/// Named profiles (like SSH hosts)
|
||||
#[serde(default)]
|
||||
pub profiles: HashMap<String, Profile>,
|
||||
/// Default output format (can be overridden per-profile)
|
||||
#[serde(default = "default_output_format")]
|
||||
pub default_output_format: String,
|
||||
}
|
||||
|
||||
fn default_profile_name() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
fn default_output_format() -> String {
|
||||
"table".to_string()
|
||||
}
|
||||
|
||||
/// A named profile for connecting to an Attune server
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
/// API endpoint URL
|
||||
pub api_url: String,
|
||||
/// Authentication token
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_token: Option<String>,
|
||||
/// Refresh token
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
/// Output format override for this profile
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_format: Option<String>,
|
||||
/// Optional description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CliConfig {
|
||||
fn default() -> Self {
|
||||
let mut profiles = HashMap::new();
|
||||
profiles.insert(
|
||||
"default".to_string(),
|
||||
Profile {
|
||||
api_url: "http://localhost:8080".to_string(),
|
||||
auth_token: None,
|
||||
refresh_token: None,
|
||||
output_format: None,
|
||||
description: Some("Default local server".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
current_profile: "default".to_string(),
|
||||
profiles,
|
||||
default_output_format: default_output_format(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CliConfig {
|
||||
/// Get the configuration file path
|
||||
pub fn config_path() -> Result<PathBuf> {
|
||||
// Respect XDG_CONFIG_HOME environment variable (for tests and user overrides)
|
||||
let config_dir = if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
|
||||
PathBuf::from(xdg_config)
|
||||
} else {
|
||||
dirs::config_dir().context("Failed to determine config directory")?
|
||||
};
|
||||
|
||||
let attune_config_dir = config_dir.join("attune");
|
||||
fs::create_dir_all(&attune_config_dir).context("Failed to create config directory")?;
|
||||
|
||||
Ok(attune_config_dir.join("config.yaml"))
|
||||
}
|
||||
|
||||
/// Load configuration from file, or create default if not exists
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::config_path()?;
|
||||
|
||||
if !path.exists() {
|
||||
let config = Self::default();
|
||||
config.save()?;
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path).context("Failed to read config file")?;
|
||||
|
||||
let config: Self =
|
||||
serde_yaml_ng::from_str(&content).context("Failed to parse config file")?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Save configuration to file
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::config_path()?;
|
||||
|
||||
let content = serde_yaml_ng::to_string(self).context("Failed to serialize config")?;
|
||||
|
||||
fs::write(&path, content).context("Failed to write config file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current active profile
|
||||
pub fn current_profile(&self) -> Result<&Profile> {
|
||||
self.profiles
|
||||
.get(&self.current_profile)
|
||||
.context(format!("Profile '{}' not found", self.current_profile))
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the current profile
|
||||
pub fn current_profile_mut(&mut self) -> Result<&mut Profile> {
|
||||
let profile_name = self.current_profile.clone();
|
||||
self.profiles
|
||||
.get_mut(&profile_name)
|
||||
.context(format!("Profile '{}' not found", profile_name))
|
||||
}
|
||||
|
||||
/// Get a profile by name
|
||||
pub fn get_profile(&self, name: &str) -> Option<&Profile> {
|
||||
self.profiles.get(name)
|
||||
}
|
||||
|
||||
/// Switch to a different profile
|
||||
pub fn switch_profile(&mut self, name: String) -> Result<()> {
|
||||
if !self.profiles.contains_key(&name) {
|
||||
anyhow::bail!("Profile '{}' does not exist", name);
|
||||
}
|
||||
self.current_profile = name;
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Add or update a profile
|
||||
pub fn set_profile(&mut self, name: String, profile: Profile) -> Result<()> {
|
||||
self.profiles.insert(name, profile);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Remove a profile
|
||||
pub fn remove_profile(&mut self, name: &str) -> Result<()> {
|
||||
if self.current_profile == name {
|
||||
anyhow::bail!("Cannot remove active profile");
|
||||
}
|
||||
if name == "default" {
|
||||
anyhow::bail!("Cannot remove the default profile");
|
||||
}
|
||||
self.profiles.remove(name);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// List all profile names
|
||||
pub fn list_profiles(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = self.profiles.keys().cloned().collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Set the API URL for the current profile
|
||||
///
|
||||
/// Part of configuration management API - used by `attune config set api-url` command
|
||||
#[allow(dead_code)]
|
||||
pub fn set_api_url(&mut self, url: String) -> Result<()> {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.api_url = url;
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Set authentication tokens for the current profile
|
||||
pub fn set_auth(&mut self, access_token: String, refresh_token: String) -> Result<()> {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.auth_token = Some(access_token);
|
||||
profile.refresh_token = Some(refresh_token);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Clear authentication tokens for the current profile
|
||||
pub fn clear_auth(&mut self) -> Result<()> {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.auth_token = None;
|
||||
profile.refresh_token = None;
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Set a configuration value by key
|
||||
pub fn set_value(&mut self, key: &str, value: String) -> Result<()> {
|
||||
match key {
|
||||
"api_url" => {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.api_url = value;
|
||||
}
|
||||
"output_format" => {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.output_format = Some(value);
|
||||
}
|
||||
"default_output_format" => {
|
||||
self.default_output_format = value;
|
||||
}
|
||||
"current_profile" => {
|
||||
self.switch_profile(value)?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => anyhow::bail!("Unknown config key: {}", key),
|
||||
}
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Get a configuration value by key
|
||||
pub fn get_value(&self, key: &str) -> Result<String> {
|
||||
match key {
|
||||
"api_url" => {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile.api_url.clone())
|
||||
}
|
||||
"output_format" => {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile
|
||||
.output_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.default_output_format.clone()))
|
||||
}
|
||||
"default_output_format" => Ok(self.default_output_format.clone()),
|
||||
"current_profile" => Ok(self.current_profile.clone()),
|
||||
"auth_token" => {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile
|
||||
.auth_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string())
|
||||
}
|
||||
"refresh_token" => {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string())
|
||||
}
|
||||
_ => anyhow::bail!("Unknown config key: {}", key),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all configuration keys and values for current profile
|
||||
pub fn list_all(&self) -> Vec<(String, String)> {
|
||||
let profile = match self.current_profile() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
vec![
|
||||
("current_profile".to_string(), self.current_profile.clone()),
|
||||
("api_url".to_string(), profile.api_url.clone()),
|
||||
(
|
||||
"output_format".to_string(),
|
||||
profile
|
||||
.output_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.default_output_format.clone()),
|
||||
),
|
||||
(
|
||||
"default_output_format".to_string(),
|
||||
self.default_output_format.clone(),
|
||||
),
|
||||
(
|
||||
"auth_token".to_string(),
|
||||
profile
|
||||
.auth_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
"refresh_token".to_string(),
|
||||
profile
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|_| "***")
|
||||
.unwrap_or("(not set)")
|
||||
.to_string(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Load configuration with optional profile override (without saving)
|
||||
///
|
||||
/// Used by `--profile` flag to temporarily use a different profile
|
||||
pub fn load_with_profile(profile_name: Option<&str>) -> Result<Self> {
|
||||
let mut config = Self::load()?;
|
||||
|
||||
if let Some(name) = profile_name {
|
||||
// Temporarily switch profile without saving
|
||||
if !config.profiles.contains_key(name) {
|
||||
anyhow::bail!("Profile '{}' does not exist", name);
|
||||
}
|
||||
config.current_profile = name.to_string();
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get the effective API URL (from override, current profile, or default)
|
||||
pub fn effective_api_url(&self, override_url: &Option<String>) -> String {
|
||||
if let Some(url) = override_url {
|
||||
return url.clone();
|
||||
}
|
||||
|
||||
if let Ok(profile) = self.current_profile() {
|
||||
profile.api_url.clone()
|
||||
} else {
|
||||
"http://localhost:8080".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get API URL for current profile (without override)
|
||||
#[allow(unused)]
|
||||
pub fn api_url(&self) -> Result<String> {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile.api_url.clone())
|
||||
}
|
||||
|
||||
/// Get auth token for current profile
|
||||
pub fn auth_token(&self) -> Result<Option<String>> {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile.auth_token.clone())
|
||||
}
|
||||
|
||||
/// Get refresh token for current profile
|
||||
pub fn refresh_token(&self) -> Result<Option<String>> {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile.refresh_token.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = CliConfig::default();
|
||||
assert_eq!(config.current_profile, "default");
|
||||
assert_eq!(config.default_output_format, "table");
|
||||
assert!(config.profiles.contains_key("default"));
|
||||
|
||||
let profile = config.current_profile().unwrap();
|
||||
assert_eq!(profile.api_url, "http://localhost:8080");
|
||||
assert!(profile.auth_token.is_none());
|
||||
assert!(profile.refresh_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_api_url() {
|
||||
let config = CliConfig::default();
|
||||
|
||||
// No override
|
||||
assert_eq!(config.effective_api_url(&None), "http://localhost:8080");
|
||||
|
||||
// With override
|
||||
let override_url = Some("http://example.com".to_string());
|
||||
assert_eq!(
|
||||
config.effective_api_url(&override_url),
|
||||
"http://example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_management() {
|
||||
let mut config = CliConfig::default();
|
||||
|
||||
// Add a new profile
|
||||
let staging_profile = Profile {
|
||||
api_url: "https://staging.example.com".to_string(),
|
||||
auth_token: None,
|
||||
refresh_token: None,
|
||||
output_format: Some("json".to_string()),
|
||||
description: Some("Staging environment".to_string()),
|
||||
};
|
||||
config
|
||||
.set_profile("staging".to_string(), staging_profile)
|
||||
.unwrap();
|
||||
|
||||
// List profiles
|
||||
let profiles = config.list_profiles();
|
||||
assert!(profiles.contains(&"default".to_string()));
|
||||
assert!(profiles.contains(&"staging".to_string()));
|
||||
|
||||
// Switch to staging
|
||||
config.switch_profile("staging".to_string()).unwrap();
|
||||
assert_eq!(config.current_profile, "staging");
|
||||
|
||||
let profile = config.current_profile().unwrap();
|
||||
assert_eq!(profile.api_url, "https://staging.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cannot_remove_default_profile() {
|
||||
let mut config = CliConfig::default();
|
||||
let result = config.remove_profile("default");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cannot_remove_active_profile() {
|
||||
let mut config = CliConfig::default();
|
||||
|
||||
let test_profile = Profile {
|
||||
api_url: "http://test.com".to_string(),
|
||||
auth_token: None,
|
||||
refresh_token: None,
|
||||
output_format: None,
|
||||
description: None,
|
||||
};
|
||||
config
|
||||
.set_profile("test".to_string(), test_profile)
|
||||
.unwrap();
|
||||
config.switch_profile("test".to_string()).unwrap();
|
||||
|
||||
let result = config.remove_profile("test");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_set_value() {
|
||||
let mut config = CliConfig::default();
|
||||
|
||||
assert_eq!(
|
||||
config.get_value("api_url").unwrap(),
|
||||
"http://localhost:8080"
|
||||
);
|
||||
assert_eq!(config.get_value("output_format").unwrap(), "table");
|
||||
|
||||
// Set API URL for current profile
|
||||
config
|
||||
.set_value("api_url", "http://test.com".to_string())
|
||||
.unwrap();
|
||||
assert_eq!(config.get_value("api_url").unwrap(), "http://test.com");
|
||||
|
||||
// Set output format for current profile
|
||||
config
|
||||
.set_value("output_format", "json".to_string())
|
||||
.unwrap();
|
||||
assert_eq!(config.get_value("output_format").unwrap(), "json");
|
||||
}
|
||||
}
|
||||
218
crates/cli/src/main.rs
Normal file
218
crates/cli/src/main.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::process;
|
||||
|
||||
mod client;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod output;
|
||||
|
||||
use commands::{
|
||||
action::{handle_action_command, ActionCommands},
|
||||
auth::AuthCommands,
|
||||
config::ConfigCommands,
|
||||
execution::ExecutionCommands,
|
||||
pack::PackCommands,
|
||||
rule::RuleCommands,
|
||||
sensor::SensorCommands,
|
||||
trigger::TriggerCommands,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "attune")]
|
||||
#[command(author, version, about = "Attune CLI - Event-driven automation platform", long_about = None)]
|
||||
#[command(propagate_version = true)]
|
||||
struct Cli {
|
||||
/// Profile to use (overrides config)
|
||||
#[arg(short = 'p', long, env = "ATTUNE_PROFILE", global = true)]
|
||||
profile: Option<String>,
|
||||
|
||||
/// API endpoint URL (overrides config)
|
||||
#[arg(long, env = "ATTUNE_API_URL", global = true)]
|
||||
api_url: Option<String>,
|
||||
|
||||
/// Output format
|
||||
#[arg(long, value_enum, default_value = "table", global = true, conflicts_with_all = ["json", "yaml"])]
|
||||
output: output::OutputFormat,
|
||||
|
||||
/// Output as JSON (shorthand for --output json)
|
||||
#[arg(short = 'j', long, global = true, conflicts_with_all = ["output", "yaml"])]
|
||||
json: bool,
|
||||
|
||||
/// Output as YAML (shorthand for --output yaml)
|
||||
#[arg(short = 'y', long, global = true, conflicts_with_all = ["output", "json"])]
|
||||
yaml: bool,
|
||||
|
||||
/// Verbose logging
|
||||
#[arg(short, long, global = true)]
|
||||
verbose: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Authentication commands
|
||||
Auth {
|
||||
#[command(subcommand)]
|
||||
command: AuthCommands,
|
||||
},
|
||||
/// Pack management
|
||||
Pack {
|
||||
#[command(subcommand)]
|
||||
command: PackCommands,
|
||||
},
|
||||
/// Action management and execution
|
||||
Action {
|
||||
#[command(subcommand)]
|
||||
command: ActionCommands,
|
||||
},
|
||||
/// Rule management
|
||||
Rule {
|
||||
#[command(subcommand)]
|
||||
command: RuleCommands,
|
||||
},
|
||||
/// Execution monitoring
|
||||
Execution {
|
||||
#[command(subcommand)]
|
||||
command: ExecutionCommands,
|
||||
},
|
||||
/// Trigger management
|
||||
Trigger {
|
||||
#[command(subcommand)]
|
||||
command: TriggerCommands,
|
||||
},
|
||||
/// Sensor management
|
||||
Sensor {
|
||||
#[command(subcommand)]
|
||||
command: SensorCommands,
|
||||
},
|
||||
/// Configuration management
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
command: ConfigCommands,
|
||||
},
|
||||
/// Run an action (shortcut for 'action execute')
|
||||
Run {
|
||||
/// Action reference (pack.action)
|
||||
action_ref: String,
|
||||
|
||||
/// Action parameters in key=value format
|
||||
#[arg(long)]
|
||||
param: Vec<String>,
|
||||
|
||||
/// Parameters as JSON string
|
||||
#[arg(long, conflicts_with = "param")]
|
||||
params_json: Option<String>,
|
||||
|
||||
/// Wait for execution to complete
|
||||
#[arg(short, long)]
|
||||
wait: bool,
|
||||
|
||||
/// Timeout in seconds when waiting (default: 300)
|
||||
#[arg(long, default_value = "300", requires = "wait")]
|
||||
timeout: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Initialize logging
|
||||
if cli.verbose {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
}
|
||||
|
||||
// Determine output format from flags
|
||||
let output_format = if cli.json {
|
||||
output::OutputFormat::Json
|
||||
} else if cli.yaml {
|
||||
output::OutputFormat::Yaml
|
||||
} else {
|
||||
cli.output
|
||||
};
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Auth { command } => {
|
||||
commands::auth::handle_auth_command(&cli.profile, command, &cli.api_url, output_format)
|
||||
.await
|
||||
}
|
||||
Commands::Pack { command } => {
|
||||
commands::pack::handle_pack_command(&cli.profile, command, &cli.api_url, output_format)
|
||||
.await
|
||||
}
|
||||
Commands::Action { command } => {
|
||||
commands::action::handle_action_command(
|
||||
&cli.profile,
|
||||
command,
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Rule { command } => {
|
||||
commands::rule::handle_rule_command(&cli.profile, command, &cli.api_url, output_format)
|
||||
.await
|
||||
}
|
||||
Commands::Execution { command } => {
|
||||
commands::execution::handle_execution_command(
|
||||
&cli.profile,
|
||||
command,
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Trigger { command } => {
|
||||
commands::trigger::handle_trigger_command(
|
||||
&cli.profile,
|
||||
command,
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Sensor { command } => {
|
||||
commands::sensor::handle_sensor_command(
|
||||
&cli.profile,
|
||||
command,
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Config { command } => {
|
||||
commands::config::handle_config_command(&cli.profile, command, output_format).await
|
||||
}
|
||||
Commands::Run {
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
wait,
|
||||
timeout,
|
||||
} => {
|
||||
// Delegate to action execute command
|
||||
handle_action_command(
|
||||
&cli.profile,
|
||||
ActionCommands::Execute {
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
wait,
|
||||
timeout,
|
||||
},
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
167
crates/cli/src/output.rs
Normal file
167
crates/cli/src/output.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use anyhow::Result;
|
||||
use clap::ValueEnum;
|
||||
use colored::Colorize;
|
||||
use comfy_table::{modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, Cell, Color, Table};
|
||||
use serde::Serialize;
|
||||
use std::fmt::Display;
|
||||
|
||||
/// Output format for CLI commands
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq)]
|
||||
pub enum OutputFormat {
|
||||
/// Human-readable table format
|
||||
Table,
|
||||
/// JSON format for scripting
|
||||
Json,
|
||||
/// YAML format
|
||||
Yaml,
|
||||
}
|
||||
|
||||
impl Display for OutputFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
OutputFormat::Table => write!(f, "table"),
|
||||
OutputFormat::Json => write!(f, "json"),
|
||||
OutputFormat::Yaml => write!(f, "yaml"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Print output in the specified format
|
||||
pub fn print_output<T: Serialize>(data: &T, format: OutputFormat) -> Result<()> {
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
let json = serde_json::to_string_pretty(data)?;
|
||||
println!("{}", json);
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
let yaml = serde_yaml_ng::to_string(data)?;
|
||||
println!("{}", yaml);
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
// For table format, the caller should use specific table functions
|
||||
let json = serde_json::to_string_pretty(data)?;
|
||||
println!("{}", json);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print a success message
|
||||
pub fn print_success(message: &str) {
|
||||
println!("{} {}", "✓".green().bold(), message);
|
||||
}
|
||||
|
||||
/// Print an info message
|
||||
pub fn print_info(message: &str) {
|
||||
println!("{} {}", "ℹ".blue().bold(), message);
|
||||
}
|
||||
|
||||
/// Print a warning message
|
||||
pub fn print_warning(message: &str) {
|
||||
eprintln!("{} {}", "⚠".yellow().bold(), message);
|
||||
}
|
||||
|
||||
/// Print an error message
|
||||
pub fn print_error(message: &str) {
|
||||
eprintln!("{} {}", "✗".red().bold(), message);
|
||||
}
|
||||
|
||||
/// Create a new table with default styling
|
||||
pub fn create_table() -> Table {
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.apply_modifier(UTF8_ROUND_CORNERS);
|
||||
table
|
||||
}
|
||||
|
||||
/// Add a header row to a table with styling
|
||||
pub fn add_header(table: &mut Table, headers: Vec<&str>) {
|
||||
let cells: Vec<Cell> = headers
|
||||
.into_iter()
|
||||
.map(|h| Cell::new(h).fg(Color::Cyan))
|
||||
.collect();
|
||||
table.set_header(cells);
|
||||
}
|
||||
|
||||
/// Print a table of key-value pairs
|
||||
pub fn print_key_value_table(pairs: Vec<(&str, String)>) {
|
||||
let mut table = create_table();
|
||||
add_header(&mut table, vec!["Key", "Value"]);
|
||||
|
||||
for (key, value) in pairs {
|
||||
table.add_row(vec![Cell::new(key).fg(Color::Yellow), Cell::new(value)]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
|
||||
/// Print a simple list
|
||||
pub fn print_list(items: Vec<String>) {
|
||||
for item in items {
|
||||
println!(" • {}", item);
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a titled section
|
||||
pub fn print_section(title: &str) {
|
||||
println!("\n{}", title.bold().underline());
|
||||
}
|
||||
|
||||
/// Format a boolean as a colored checkmark or cross
|
||||
pub fn format_bool(value: bool) -> String {
|
||||
if value {
|
||||
"✓".green().to_string()
|
||||
} else {
|
||||
"✗".red().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a status with color
|
||||
pub fn format_status(status: &str) -> String {
|
||||
match status.to_lowercase().as_str() {
|
||||
"succeeded" | "success" | "enabled" | "active" | "running" => status.green().to_string(),
|
||||
"failed" | "error" | "disabled" | "inactive" => status.red().to_string(),
|
||||
"pending" | "scheduled" | "queued" => status.yellow().to_string(),
|
||||
"canceled" | "cancelled" => status.bright_black().to_string(),
|
||||
_ => status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to a maximum length with ellipsis
|
||||
pub fn truncate(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max_len.saturating_sub(3)])
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a timestamp in a human-readable way
|
||||
pub fn format_timestamp(timestamp: &str) -> String {
|
||||
// Try to parse and format nicely, otherwise return as-is
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
} else {
|
||||
timestamp.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_truncate() {
|
||||
assert_eq!(truncate("short", 10), "short");
|
||||
assert_eq!(truncate("this is a long string", 10), "this is...");
|
||||
assert_eq!(truncate("exactly10!", 10), "exactly10!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_display() {
|
||||
assert_eq!(OutputFormat::Table.to_string(), "table");
|
||||
assert_eq!(OutputFormat::Json.to_string(), "json");
|
||||
assert_eq!(OutputFormat::Yaml.to_string(), "yaml");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user