mirror of
https://github.com/coralogix/cx-cli.git
synced 2026-09-14 16:15:45 +08:00
f688e2fadc
Co-authored-by: Snir Shechter <snir.shechter@coralogix.com>
764 lines
25 KiB
Markdown
764 lines
25 KiB
Markdown
# Adding a Command
|
|
|
|
> Step-by-step guide for adding a new command to `cx`. Read [architecture.md](architecture.md) first for the execution flow and design decisions behind this structure.
|
|
|
|
## Choose your archetype
|
|
|
|
Every command falls into one of two patterns:
|
|
|
|
| Archetype | When to use | Reference implementation |
|
|
|-----------|------------|--------------------------|
|
|
| **A: DataPrime-based** | Querying logs, spans, or any DataPrime source | `logs` (`src/commands/logs/mod.rs`) |
|
|
| **B: REST-based** | Wrapping a Coralogix REST API | `alerts` (`src/commands/alerts/mod.rs`, `src/commands/alerts/api.rs`) |
|
|
|
|
> **Important:** All API integrations must use REST (HTTP). Do not use gRPC - the CLI is HTTP-only by design.
|
|
|
|
DataPrime commands delegate to the shared pipeline and require minimal code. REST commands manage their own fan-out, merge, and render - more code, but more control.
|
|
|
|
---
|
|
|
|
## Archetype A: DataPrime-based command
|
|
|
|
Use this when your command queries a DataPrime source. The shared pipeline in `commands::dataprime` handles fan-out, merge, render, and spilling - you provide only a text renderer and a thin `run()` wrapper.
|
|
|
|
**Files to create/modify:**
|
|
- `src/commands/your_domain/mod.rs` (new)
|
|
- `src/commands/mod.rs` (add module)
|
|
- `src/main.rs` (CLI definition + dispatch)
|
|
|
|
### Step 1: Command module
|
|
|
|
Create `src/commands/your_domain/mod.rs`:
|
|
|
|
```rust
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use colored::Colorize;
|
|
use serde_json::Value;
|
|
|
|
use crate::commands::dataprime::MergedResults;
|
|
use crate::config::OutputFormat;
|
|
use crate::execution::ExecutionTarget;
|
|
use crate::Tier;
|
|
|
|
// ── Text renderer ──────────────────────────────────────────────────
|
|
|
|
pub fn render_your_domain_text(merged: &MergedResults) -> Result<()> {
|
|
if merged.rows.is_empty() {
|
|
println!("{}", "No results found.".yellow());
|
|
return Ok(());
|
|
}
|
|
|
|
// Aggregate queries return raw JSON - no custom rendering.
|
|
if merged.is_aggregate {
|
|
for row in &merged.rows {
|
|
println!("{}", serde_json::to_string_pretty(row)?);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
for row in &merged.rows {
|
|
// Multi-profile: prefix each line with [profile_name]
|
|
let profile = if merged.include_profile {
|
|
row.get("profile")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| format!("[{s}] "))
|
|
.unwrap_or_default()
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
// Extract your domain-specific fields from the JSON row.
|
|
// Use row.pointer("/path/to/field") for nested access.
|
|
let field_a = row.pointer("/metadata/some_field")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("-");
|
|
|
|
println!("{}{}", profile.dimmed(), field_a);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ── Orchestrator ───────────────────────────────────────────────────
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn run(
|
|
targets: &[Arc<ExecutionTarget>],
|
|
query: &str,
|
|
start: &str,
|
|
end: &str,
|
|
limit: u32,
|
|
tier: Tier,
|
|
output: OutputFormat,
|
|
max_direct: Option<usize>,
|
|
temp_dir: &str,
|
|
) -> Result<()> {
|
|
super::dataprime::run_query(
|
|
targets,
|
|
query,
|
|
"your_source", // DataPrime source name (e.g., "logs", "spans")
|
|
start,
|
|
end,
|
|
limit,
|
|
tier,
|
|
output,
|
|
max_direct,
|
|
temp_dir,
|
|
Some(render_your_domain_text),
|
|
)
|
|
.await
|
|
}
|
|
```
|
|
|
|
The text renderer signature must be `fn(&MergedResults) -> Result<()>`. The shared pipeline calls it only for `OutputFormat::Text` - JSON and Toon output are handled generically.
|
|
|
|
**Reference:** `src/commands/logs/mod.rs` - the entire module is ~130 lines.
|
|
|
|
### Step 2: Register the module
|
|
|
|
Add your module to `src/commands/mod.rs`:
|
|
|
|
```rust
|
|
pub mod your_domain;
|
|
```
|
|
|
|
### Step 3: CLI wiring
|
|
|
|
In `src/main.rs`, add a variant to the `Commands` enum:
|
|
|
|
```rust
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
// ... existing variants ...
|
|
|
|
/// Query your-domain data using DataPrime syntax.
|
|
#[command(after_help = "\
|
|
Examples:
|
|
cx your-domain 'filter $d.field == \"value\"'
|
|
cx your-domain 'filter $m.severity == ERROR' --start now-6h")]
|
|
YourDomain {
|
|
/// DataPrime query string.
|
|
query: String,
|
|
|
|
#[arg(long, default_value = "now-1h")]
|
|
start: String,
|
|
|
|
#[arg(long, default_value = "now")]
|
|
end: String,
|
|
|
|
#[arg(long, default_value_t = 100)]
|
|
limit: u32,
|
|
|
|
#[arg(long, default_value = "frequent")]
|
|
tier: Tier,
|
|
},
|
|
}
|
|
```
|
|
|
|
Add the dispatch match arm (after config resolution, inside the `match cli.command` block):
|
|
|
|
```rust
|
|
Commands::YourDomain {
|
|
query,
|
|
start,
|
|
end,
|
|
limit,
|
|
tier,
|
|
} => {
|
|
commands::your_domain::run(
|
|
&targets, &query, &start, &end, limit, tier, output, max_direct, &temp_dir,
|
|
)
|
|
.await?;
|
|
}
|
|
```
|
|
|
|
That's it for a DataPrime command. The shared pipeline handles fan-out, merge, toon output, and spilling.
|
|
|
|
---
|
|
|
|
## Archetype B: REST-based command
|
|
|
|
Use this when your command wraps a Coralogix REST API. You'll build the full pipeline: API client, fan-out, merge, and render.
|
|
|
|
**Files to create/modify:**
|
|
- `src/commands/your_domain/api.rs` (new - API types + client)
|
|
- `src/commands/your_domain/mod.rs` (new - handler with `pub mod api;` + fan-out, merge, render)
|
|
- `src/commands/mod.rs` (add `pub mod your_domain;`)
|
|
- `src/main.rs` (CLI definition + dispatch)
|
|
|
|
### Step 1: API module
|
|
|
|
Create `src/commands/your_domain/api.rs`:
|
|
|
|
```rust
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
|
|
use crate::error::Result;
|
|
|
|
use crate::api_client::CxClient;
|
|
|
|
// ── Response types ─────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct YourItem {
|
|
pub id: Option<String>,
|
|
pub name: Option<String>,
|
|
// Add fields matching the API's JSON response.
|
|
// Use Option<T> for fields that may be absent.
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ListResponse {
|
|
#[serde(default)]
|
|
pub items: Vec<YourItem>,
|
|
}
|
|
|
|
// ── API client ─────────────────────────────────────────────────────
|
|
|
|
const BASE_PATH: &str = "/mgmt/openapi/5/your-domain/v1";
|
|
|
|
pub struct YourDomainApi<'a> {
|
|
client: &'a CxClient,
|
|
}
|
|
|
|
impl<'a> YourDomainApi<'a> {
|
|
pub fn new(client: &'a CxClient) -> Self {
|
|
Self { client }
|
|
}
|
|
|
|
pub async fn list(&self) -> Result<ListResponse> {
|
|
self.client.get(BASE_PATH, &[]).await
|
|
}
|
|
|
|
pub async fn get(&self, id: &str) -> Result<Value> {
|
|
let path = format!("{BASE_PATH}/{id}");
|
|
self.client.get(&path, &[]).await
|
|
}
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn deserialize_list_response() {
|
|
let json = json!({
|
|
"items": [
|
|
{ "id": "abc-123", "name": "First Item" },
|
|
{ "id": "def-456", "name": "Second Item" }
|
|
]
|
|
});
|
|
let resp: ListResponse = serde_json::from_value(json).unwrap();
|
|
assert_eq!(resp.items.len(), 2);
|
|
assert_eq!(resp.items[0].id.as_deref(), Some("abc-123"));
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_empty_list() {
|
|
let json = json!({});
|
|
let resp: ListResponse = serde_json::from_value(json).unwrap();
|
|
assert!(resp.items.is_empty());
|
|
}
|
|
}
|
|
```
|
|
|
|
Key conventions:
|
|
- Response types derive `Deserialize` with `#[serde(rename_all = "camelCase")]`
|
|
- Use `#[serde(default)]` on Vec fields so missing keys deserialize to empty
|
|
- The API struct borrows `&CxClient` - use the appropriate method (`get`, `post`, `post_raw`, `post_empty`) based on the endpoint
|
|
- Always write deserialization tests against realistic JSON fixtures
|
|
|
|
**Reference:** `src/commands/alerts/api.rs` - full example with list, get, create, and state-change endpoints.
|
|
|
|
### Step 2: Command module
|
|
|
|
Create `src/commands/your_domain/mod.rs`:
|
|
|
|
```rust
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use colored::Colorize;
|
|
use serde_json::{json, Value};
|
|
use toon_format::encode_default as toon_encode;
|
|
|
|
pub mod api;
|
|
|
|
use api::YourDomainApi;
|
|
|
|
use crate::config::OutputFormat;
|
|
use crate::execution::{fan_out, ExecutionTarget};
|
|
use crate::render;
|
|
|
|
// ── Subcommand runner ──────────────────────────────────────────────
|
|
|
|
pub async fn run_list(
|
|
targets: &[Arc<ExecutionTarget>],
|
|
output: OutputFormat,
|
|
) -> Result<()> {
|
|
eprintln!("{}", "Fetching items...".dimmed());
|
|
|
|
let include_profile = targets.len() > 1;
|
|
|
|
// 1. Fan-out: call the API across all profiles concurrently.
|
|
let per_profile = fan_out(targets, |t| async move {
|
|
let api = YourDomainApi::new(&t.client);
|
|
Ok(api.list().await?)
|
|
})
|
|
.await;
|
|
|
|
// 2. Merge: collect results, print per-profile errors to stderr.
|
|
let mut all_json: Vec<Value> = Vec::new();
|
|
let mut all_items: Vec<(String, String, String)> = Vec::new(); // (profile, id, name)
|
|
for (profile, result) in per_profile {
|
|
match result {
|
|
Ok(resp) => {
|
|
for item in resp.items {
|
|
let id = item.id.clone().unwrap_or_default();
|
|
let name = item.name.clone().unwrap_or_default();
|
|
let mut j = json!({ "id": id, "name": name });
|
|
if include_profile {
|
|
j.as_object_mut().unwrap()
|
|
.insert("profile".to_string(), Value::String(profile.clone()));
|
|
}
|
|
all_json.push(j);
|
|
all_items.push((profile.clone(), id, name));
|
|
}
|
|
}
|
|
Err(e) => eprintln!("{}", format!("error from profile '{profile}': {e:#}").red()),
|
|
}
|
|
}
|
|
|
|
// 3. Render: match on output format.
|
|
match output {
|
|
OutputFormat::Json => render::render_json(&all_json)?,
|
|
OutputFormat::Toon => {
|
|
let toon =
|
|
toon_encode(&all_json).map_err(|e| anyhow::anyhow!("TOON encoding failed: {e}"))?;
|
|
println!("{toon}");
|
|
}
|
|
OutputFormat::Text => {
|
|
if all_items.is_empty() {
|
|
render::print_no_results("No items found.");
|
|
return Ok(());
|
|
}
|
|
let rows: Vec<Vec<String>> = all_items
|
|
.iter()
|
|
.map(|(profile, id, name)| {
|
|
vec![profile.clone(), id.clone(), name.clone()]
|
|
})
|
|
.collect();
|
|
render::render_table(&["ID", "Name"], rows, include_profile);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
Key patterns to follow:
|
|
- **`include_profile = targets.len() > 1`** - this boolean controls all multi-profile behavior
|
|
- **`render::render_table`** handles the Profile column automatically - pass headers without "Profile", and put the profile name as the first element of each row. The helper conditionally includes/excludes it based on `include_profile`.
|
|
- **Toon output is command-owned** - each command calls `toon_encode` directly after any post-processing it needs
|
|
- **Fan-out errors are non-fatal** - print to stderr, continue with successful profiles
|
|
- **Status messages go to stderr** - use `eprintln!` so they don't pollute piped output
|
|
|
|
**Reference:** `src/commands/dashboards/mod.rs` - clean example with list and get subcommands using `render::*` helpers.
|
|
|
|
### Step 3: Register the command module
|
|
|
|
Add to `src/commands/mod.rs`:
|
|
|
|
```rust
|
|
pub mod your_domain;
|
|
```
|
|
|
|
### Step 4: CLI wiring
|
|
|
|
In `src/main.rs`, define the subcommand enum and add to `Commands`:
|
|
|
|
```rust
|
|
#[derive(Subcommand)]
|
|
enum YourDomainCmd {
|
|
/// List all items.
|
|
List,
|
|
/// Get a single item by ID.
|
|
Get {
|
|
/// Item ID.
|
|
item_id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
// ... existing variants ...
|
|
|
|
/// Manage your-domain resources.
|
|
YourDomain {
|
|
#[command(subcommand)]
|
|
cmd: YourDomainCmd,
|
|
},
|
|
}
|
|
```
|
|
|
|
Add the dispatch match arm:
|
|
|
|
```rust
|
|
Commands::YourDomain { cmd } => match cmd {
|
|
YourDomainCmd::List => {
|
|
commands::your_domain::run_list(&targets, output).await?;
|
|
}
|
|
YourDomainCmd::Get { item_id } => {
|
|
commands::your_domain::run_get(&targets, &item_id, output).await?;
|
|
}
|
|
},
|
|
```
|
|
|
|
### Step 6: Update the help display
|
|
|
|
The `Cli` struct uses a custom `after_help` string (not Clap's `next_help_heading` or `flatten`) to display grouped command categories. After adding a command to the `Commands` enum, add a line for it in the `after_help` string under the appropriate category group:
|
|
|
|
```rust
|
|
#[command(
|
|
help_template = "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nGlobal Options:\n{options}",
|
|
after_help = "\
|
|
Query:
|
|
logs Query logs using DataPrime syntax
|
|
...
|
|
Data Pipeline:
|
|
parsing-rules Manage log parsing rules
|
|
your-domain Your domain description <-- add here
|
|
..."
|
|
)]
|
|
```
|
|
|
|
Place the command in the category that best fits its purpose. See `main.rs` for the full list of categories: Query, Observe, Detect & Respond, Notifications, Data Pipeline, Cost & Storage, Integrations, Access, Agent, Local.
|
|
|
|
### Step 7: Guard destructive operations (REST archetype)
|
|
|
|
If your command has write operations (create, update, delete, enable, disable, etc.), guard them with a confirmation prompt using `confirm_destructive()` from `src/safety.rs`. This prevents accidental execution in interactive terminals and blocks non-interactive callers unless they pass `--yes`.
|
|
|
|
1. Tag the subcommand doc comment with `[requires --yes]`:
|
|
|
|
```rust
|
|
/// Delete an item [requires --yes].
|
|
Delete {
|
|
/// Item ID.
|
|
id: String,
|
|
},
|
|
```
|
|
|
|
2. Add a `confirm_destructive()` call in the dispatch match arm, before the handler:
|
|
|
|
```rust
|
|
YourDomainCmd::Delete { id } => {
|
|
confirm_destructive(&format!("Delete item '{id}'?"), yes, agent_mode)?;
|
|
commands::your_domain::run_delete(&targets, &id).await?;
|
|
}
|
|
```
|
|
|
|
The `yes` variable is the global `--yes` flag, already available in the match scope. Read-only operations (list, get, search) should NOT have confirmation prompts.
|
|
|
|
If your command is risky enough to warrant a `(risky)` tag on the parent command in `cx --help`, add it to the `after_help` string (e.g., `your-domain (risky)`).
|
|
|
|
---
|
|
|
|
## Adding a subcommand to a wrapper command
|
|
|
|
Some commands group multiple related domains under a single CLI entry point using a **wrapper enum**. Examples: `iam` (api-keys, roles, scopes, users, groups, ip-access), `notifications` (connectors, routers, presets, test), `integrations` (extensions, contextual-data).
|
|
|
|
If your new functionality belongs under an existing wrapper, you do not create a new top-level command. Instead:
|
|
|
|
1. **Create your API and command modules** as usual (`src/commands/your_sub/api.rs`, `src/commands/your_sub/mod.rs`)
|
|
2. **Add a variant to the wrapper enum** (e.g., `IamCmd`, `NotificationsCmd`) in `main.rs`:
|
|
|
|
```rust
|
|
#[derive(Subcommand)]
|
|
enum IamCmd {
|
|
// ... existing variants ...
|
|
|
|
/// Manage your-sub resources.
|
|
YourSub {
|
|
#[command(subcommand)]
|
|
cmd: YourSubCmd,
|
|
},
|
|
}
|
|
```
|
|
|
|
3. **Add the leaf subcommand enum** (`YourSubCmd`) with its operations (list, get, etc.)
|
|
4. **Add dispatch** in the existing wrapper's match arm
|
|
5. **Update the wrapper's `after_help`** in the `Commands` enum variant to include the new sub-domain in its examples
|
|
|
|
The wrapper variant in `Commands` already appears in the top-level `after_help`, so no change is needed there unless the wrapper's description should be updated.
|
|
|
|
**Reference:** See `IamCmd` in `main.rs` for a wrapper with seven sub-domains, or `NotificationsCmd` for a wrapper with four.
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
Every new command must add tests at three layers: **unit**, **integration**,
|
|
and **e2e**. Each layer catches different categories of regressions -
|
|
skipping any of them leaves real holes.
|
|
|
|
| Layer | Location | What it verifies | Network |
|
|
|-------|----------|------------------|---------|
|
|
| Unit | `src/**/<file>.rs` `#[cfg(test)]` blocks | Pure logic - deserialization, formatting helpers, data transforms | None |
|
|
| Integration | `tests/<domain>.rs` (wiremock) | Command runner end-to-end with mocked HTTP responses | None |
|
|
| E2E | `tests/e2e/<command>/mod.rs` (assert_cmd) | Real `cx` binary runs against the Coralogix test team | Real |
|
|
|
|
### Layer 1 - Unit tests
|
|
|
|
#### Deserialization tests (REST archetype, mandatory)
|
|
|
|
Every API module must have deserialization tests. These verify that your
|
|
response types correctly parse the actual API JSON shape.
|
|
|
|
```rust
|
|
// in src/commands/your_domain/api.rs
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn deserialize_list_response() {
|
|
let json = json!({
|
|
"items": [
|
|
{ "id": "abc-123", "name": "Test Item" }
|
|
]
|
|
});
|
|
let resp: ListResponse = serde_json::from_value(json).unwrap();
|
|
assert_eq!(resp.items.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_empty_response() {
|
|
let json = json!({});
|
|
let resp: ListResponse = serde_json::from_value(json).unwrap();
|
|
assert!(resp.items.is_empty());
|
|
}
|
|
}
|
|
```
|
|
|
|
Test happy-path responses **and** edge cases: empty lists, missing optional
|
|
fields, fallback values. These are the cases that break in production.
|
|
|
|
#### Helper/formatting tests (any archetype, when applicable)
|
|
|
|
If your command module has non-trivial mapping logic - building tabular
|
|
rows, transforming JSON shapes, parsing user input - add unit tests for
|
|
those helpers. See `src/commands/metrics/tests.rs` for an example.
|
|
|
|
### Layer 2 - Integration tests (wiremock)
|
|
|
|
Add `tests/<your_domain>.rs` using `wiremock` to spin up a fake Coralogix
|
|
API and call your command runner directly. This catches regressions in
|
|
fan-out, merge, rendering, and the wiring between command and API layer.
|
|
|
|
Reference: `tests/alerts/main.rs`, `tests/metrics/main.rs`, `tests/search_fields/main.rs`.
|
|
|
|
```rust
|
|
// tests/your_domain/main.rs
|
|
mod common;
|
|
|
|
use serde_json::json;
|
|
use wiremock::matchers::{method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
use coralogix_cli::commands::your_domain::run_list;
|
|
use coralogix_cli::config::OutputFormat;
|
|
|
|
#[tokio::test]
|
|
async fn list_returns_items_from_mock() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/mgmt/openapi/5/your-domain/v1"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"items": [{ "id": "abc-123", "name": "Test" }]
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let targets = vec![common::test_target("test-profile", &server.uri())];
|
|
run_list(&targets, OutputFormat::Json)
|
|
.await
|
|
.expect("run_list should succeed");
|
|
}
|
|
```
|
|
|
|
Cover at minimum: happy-path list/get, an empty response, a `--name`/filter
|
|
case if your command supports one, and the JSON output path.
|
|
|
|
### Layer 3 - E2E tests (real test team)
|
|
|
|
Add a sanity test in `tests/e2e/<your_domain>/mod.rs` that invokes the
|
|
compiled `cx` binary against a real Coralogix test team. The goal is
|
|
**only** to verify that the command runs end-to-end: exits 0, produces
|
|
non-empty stdout, and (for `-o json`) emits valid JSON. Don't assert on
|
|
output content - test team data drifts.
|
|
|
|
All e2e tests are `#[ignore]`d, so they don't run in the default
|
|
`cargo test`. CI invokes them via a separate workflow.
|
|
|
|
```rust
|
|
// tests/e2e/your_domain/mod.rs
|
|
use std::sync::OnceLock;
|
|
|
|
use crate::harness;
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn your_domain_list() {
|
|
if harness::require_creds("your_domain_list").is_none() {
|
|
return;
|
|
}
|
|
harness::run_ok_json(&["your-domain", "list", "-o", "json"]);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn your_domain_get() {
|
|
if harness::require_creds("your_domain_get").is_none() {
|
|
return;
|
|
}
|
|
let Some(id) = discover_your_domain_id() else {
|
|
eprintln!("[e2e] skipping your_domain_get: no items on test team");
|
|
return;
|
|
};
|
|
harness::run_ok_json(&["your-domain", "get", &id, "-o", "json"]);
|
|
}
|
|
|
|
/// Discover an id from `your-domain list -o json`. Cached so multiple
|
|
/// tests don't each pay for the list call.
|
|
fn discover_your_domain_id() -> Option<String> {
|
|
static CACHE: OnceLock<Option<String>> = OnceLock::new();
|
|
CACHE
|
|
.get_or_init(|| {
|
|
let stdout = harness::run_ok(&["your-domain", "list", "-o", "json"]);
|
|
let v = harness::parse_json(&stdout)?;
|
|
v.as_array()?
|
|
.iter()
|
|
.filter_map(|item| item.get("id").and_then(|x| x.as_str()))
|
|
.next()
|
|
.map(String::from)
|
|
})
|
|
.clone()
|
|
}
|
|
```
|
|
|
|
Then declare the module in `tests/e2e.rs`:
|
|
|
|
```rust
|
|
#[path = "e2e/your_domain/mod.rs"]
|
|
mod your_domain;
|
|
```
|
|
|
|
Discovery helpers stay local to each test module - see
|
|
`discover_alert_id` in `tests/e2e/alerts/mod.rs` for the pattern. They
|
|
should cache via `OnceLock` and skip (return `None`) when the test team
|
|
has no data, not panic.
|
|
|
|
**Do not exercise mutating commands** in e2e (create/delete/enable/
|
|
disable) until there's a paired-undo plan - they touch shared test team
|
|
state. Use a comment to mark them as deliberately uncovered, like the
|
|
existing block at the bottom of `tests/e2e/alerts/mod.rs`.
|
|
|
|
### Running the suites
|
|
|
|
```bash
|
|
cargo test # unit + integration
|
|
cargo test --test e2e -- --ignored --test-threads=1 # e2e (needs CX_API_KEY)
|
|
cargo clippy # lint
|
|
cargo fmt --check # format check
|
|
```
|
|
|
|
See [development.md](development.md) for the full e2e setup.
|
|
|
|
### Manual smoke testing
|
|
|
|
After building (`cargo build`), do at least one human-in-the-loop pass:
|
|
|
|
```bash
|
|
# DataPrime commands:
|
|
cx your-domain 'filter $d.field == "value"'
|
|
cx your-domain 'filter $d.field == "value"' -o json
|
|
cx your-domain 'filter $d.field == "value"' -o toon
|
|
|
|
# REST commands:
|
|
cx your-domain list
|
|
cx your-domain list -o json
|
|
cx your-domain get <id>
|
|
|
|
# Multi-profile:
|
|
cx -p prod -p staging your-domain list
|
|
```
|
|
|
|
---
|
|
|
|
## User-facing skill (required)
|
|
|
|
Every command must be covered by a skill in `skills/`. This can be a dedicated skill for the command, or a workflow skill that covers multiple related commands (e.g., `cx-cost-optimization` covers `cx usage`, `cx tco`, `cx retentions`, and `cx archive`). Check the existing workflow skills before creating a new one - your command may already be covered.
|
|
|
|
See **[Adding a Skill](adding-a-skill.md)** for the complete guide covering directory structure, frontmatter conventions, trigger phrases, reference files, and templates for both single-command and workflow skills.
|
|
|
|
**Reference:** `skills/cx-alerts/SKILL.md` (single-command) and `skills/cx-cost-optimization/SKILL.md` (workflow skill) - full examples.
|
|
|
|
---
|
|
|
|
## PR checklist
|
|
|
|
Copy this into your PR description:
|
|
|
|
```markdown
|
|
## Checklist
|
|
|
|
### API layer (REST archetype only)
|
|
- [ ] `src/commands/your_domain/api.rs` - response types with `#[derive(Deserialize)]`
|
|
- [ ] `src/commands/your_domain/api.rs` - `YourDomainApi` struct with methods
|
|
- [ ] `src/commands/your_domain/api.rs` - deserialization tests for all response types
|
|
- [ ] `src/commands/your_domain/mod.rs` - `pub mod api;` declared at the top
|
|
|
|
### Command layer
|
|
- [ ] `src/commands/your_domain/mod.rs` - subcommand runner(s) with fan-out/merge/render
|
|
- [ ] `src/commands/your_domain/mod.rs` - dual row structs for text output (multi-profile + single)
|
|
- [ ] `src/commands/your_domain/mod.rs` - all three output formats handled (Text, Json, Toon)
|
|
- [ ] `src/commands/mod.rs` - `pub mod your_domain;` registered
|
|
|
|
### CLI wiring
|
|
- [ ] `src/main.rs` - `Commands` enum variant added
|
|
- [ ] `src/main.rs` - subcommand enum added (REST) or args defined (DataPrime)
|
|
- [ ] `src/main.rs` - dispatch match arm added
|
|
- [ ] `src/main.rs` - destructive subcommands (create/update/delete) guarded with `confirm_destructive()` and tagged `[requires --yes]` in their doc comment (see `src/safety.rs`)
|
|
|
|
### Tests
|
|
- [ ] **Unit:** API deserialization tests in `src/commands/your_domain/api.rs` (REST)
|
|
- [ ] **Unit:** helper/formatting tests if the command module has non-trivial logic
|
|
- [ ] **Integration:** `tests/your_domain/main.rs` covering happy-path, empty response, and any filters via wiremock
|
|
- [ ] **E2E:** sanity test(s) in `tests/e2e/your_domain/mod.rs`, declared in `tests/e2e.rs` via `#[path]`
|
|
- [ ] **E2E:** local `discover_*` fn added to `tests/e2e/<your_domain>/mod.rs` if a subcommand needs an ID/name from the test team
|
|
|
|
### User-facing skill
|
|
- [ ] Command is covered by a skill in `skills/` (new or existing workflow skill)
|
|
- [ ] `scripts/verify-skills.sh` - all skills pass
|
|
|
|
### Verification
|
|
- [ ] `cargo build` succeeds
|
|
- [ ] `cargo test` passes (unit + integration)
|
|
- [ ] `cargo test --test e2e -- --ignored --test-threads=1` passes against the test team
|
|
- [ ] `cargo clippy` clean
|
|
- [ ] `cargo fmt --check` clean
|
|
- [ ] Manual smoke test: text, json, and toon output
|
|
- [ ] Manual smoke test: multi-profile (if applicable)
|
|
```
|