595 lines
21 KiB
Rust
595 lines
21 KiB
Rust
//! # Gmail Message Cull CLI Application
|
|
//!
|
|
//! A command-line interface for managing Gmail messages with automated retention rules.
|
|
//! This CLI provides powerful tools for querying, filtering, and managing Gmail messages
|
|
//! based on labels, age, and custom rules with built-in safety features like dry-run mode.
|
|
//!
|
|
//! ## Overview
|
|
//!
|
|
//! The CLI is built around three main command categories:
|
|
//!
|
|
//! - **Labels**: List and inspect Gmail labels for message organization
|
|
//! - **Messages**: Query, filter, and perform batch operations on Gmail messages
|
|
//! - **Rules**: Configure and execute automated message lifecycle management rules
|
|
//!
|
|
//! ## Authentication
|
|
//!
|
|
//! The CLI uses OAuth2 for Gmail API authentication with the following configuration:
|
|
//!
|
|
//! - **Configuration file**: `~/.cull-gmail/cull-gmail.toml`
|
|
//! - **Credential file**: OAuth2 credentials from Google Cloud Platform
|
|
//! - **Token storage**: Automatic token caching in `~/.cull-gmail/gmail1/`
|
|
//!
|
|
//! ## Command Structure
|
|
//!
|
|
//! ```bash
|
|
//! cull-gmail [OPTIONS] [COMMAND]
|
|
//! ```
|
|
//!
|
|
//! ### Global Options
|
|
//!
|
|
//! - `-v, --verbose...`: Increase logging verbosity (can be used multiple times)
|
|
//! - `-q, --quiet...`: Decrease logging verbosity
|
|
//! - `-h, --help`: Show help information
|
|
//! - `-V, --version`: Show version information
|
|
//!
|
|
//! ### Commands
|
|
//!
|
|
//! 1. **`labels`**: List all available Gmail labels
|
|
//! 2. **`messages`**: Query and operate on Gmail messages
|
|
//! 3. **`rules`**: Configure and execute retention rules
|
|
//!
|
|
//! ## Configuration File Format
|
|
//!
|
|
//! The CLI expects a TOML configuration file at `~/.cull-gmail/cull-gmail.toml`:
|
|
//!
|
|
//! ```toml
|
|
//! # OAuth2 credential file (required)
|
|
//! credential_file = "client_secret.json"
|
|
//!
|
|
//! # Configuration root directory
|
|
//! config_root = "h:.cull-gmail"
|
|
//!
|
|
//! # Rules configuration file
|
|
//! rules = "rules.toml"
|
|
//!
|
|
//! # Default execution mode (false = dry-run, true = execute)
|
|
//! execute = false
|
|
//! ```
|
|
//!
|
|
//! ## Safety Features
|
|
//!
|
|
//! - **Dry-run mode**: Default behavior prevents accidental data loss
|
|
//! - **Comprehensive logging**: Detailed operation tracking with multiple verbosity levels
|
|
//! - **Error handling**: Graceful error recovery with meaningful error messages
|
|
//! - **Confirmation prompts**: For destructive operations
|
|
//!
|
|
//! ## Usage Examples
|
|
//!
|
|
//! ### List Gmail Labels
|
|
//! ```bash
|
|
//! cull-gmail labels
|
|
//! ```
|
|
//!
|
|
//! ### Query Messages
|
|
//! ```bash
|
|
//! # List recent messages
|
|
//! cull-gmail messages -m 10 list
|
|
//!
|
|
//! # Find old promotional emails
|
|
//! cull-gmail messages -Q "label:promotions older_than:1y" list
|
|
//! ```
|
|
//!
|
|
//! ### Execute Rules
|
|
//! ```bash
|
|
//! # Preview rule execution (dry-run)
|
|
//! cull-gmail rules run
|
|
//!
|
|
//! # Execute rules for real
|
|
//! cull-gmail rules run --execute
|
|
//! ```
|
|
//!
|
|
//! ## Error Handling
|
|
//!
|
|
//! The CLI returns the following exit codes:
|
|
//! - **0**: Success
|
|
//! - **101**: Error (check stderr and logs for details)
|
|
//!
|
|
//! ## Logging
|
|
//!
|
|
//! Logging is controlled through command-line verbosity flags and environment variables:
|
|
//!
|
|
//! - **Default**: Info level logging for the cull-gmail crate
|
|
//! - **Verbose (`-v`)**: Debug level logging
|
|
//! - **Very Verbose (`-vv`)**: Trace level logging
|
|
//! - **Quiet (`-q`)**: Error level logging only
|
|
//!
|
|
//! Environment variable override:
|
|
//! ```bash
|
|
//! export RUST_LOG=cull_gmail=debug
|
|
//! ```
|
|
|
|
use clap::{Parser, Subcommand};
|
|
|
|
mod init_cli;
|
|
mod labels_cli;
|
|
mod messages_cli;
|
|
mod rules_cli;
|
|
mod token_cli;
|
|
|
|
use config::Config;
|
|
use cull_gmail::{ClientConfig, EolAction, GmailClient, Result, RuleProcessor, Rules};
|
|
use std::{env, error::Error as stdError};
|
|
|
|
use init_cli::InitCli;
|
|
use labels_cli::LabelsCli;
|
|
use messages_cli::MessagesCli;
|
|
use rules_cli::RulesCli;
|
|
use token_cli::{TokenCli, restore_tokens_from_string};
|
|
|
|
/// Main CLI application structure defining global options and subcommands.
|
|
///
|
|
/// This struct represents the root of the command-line interface, providing
|
|
/// global configuration options and dispatching to specific subcommands for
|
|
/// labels, messages, and rules management.
|
|
///
|
|
/// # Global Options
|
|
///
|
|
/// - **Logging**: Configurable verbosity levels for operation visibility
|
|
/// - **Subcommands**: Optional command selection (defaults to rule execution)
|
|
///
|
|
/// # Default Behavior
|
|
///
|
|
/// When no subcommand is provided, the CLI executes the default rule processing
|
|
/// workflow, loading rules from the configuration file and executing them
|
|
/// according to the current execution mode (dry-run or live).
|
|
#[derive(Parser, Debug)]
|
|
#[clap(author, version, about, long_about = None)]
|
|
struct Cli {
|
|
/// Logging verbosity control.
|
|
///
|
|
/// Use `-q` for quiet (errors only), default for info level,
|
|
/// `-v` for debug level, `-vv` for trace level.
|
|
#[clap(flatten)]
|
|
logging: clap_verbosity_flag::Verbosity,
|
|
|
|
/// Optional subcommand selection.
|
|
///
|
|
/// If not provided, the CLI will execute the default rule processing workflow.
|
|
#[command(subcommand)]
|
|
sub_command: Option<SubCmds>,
|
|
}
|
|
|
|
/// Available CLI subcommands for Gmail message management.
|
|
///
|
|
/// Each subcommand provides specialized functionality for different aspects
|
|
/// of Gmail message lifecycle management, from inspection to automated processing.
|
|
///
|
|
/// # Command Categories
|
|
///
|
|
/// - **Messages**: Direct message querying, filtering, and batch operations
|
|
/// - **Labels**: Gmail label inspection and management
|
|
/// - **Rules**: Automated message lifecycle rule configuration and execution
|
|
///
|
|
/// # Display Order
|
|
///
|
|
/// Commands are ordered by typical usage workflow: inspect labels first,
|
|
/// then query specific messages, and finally configure automated rules.
|
|
#[derive(Subcommand, Debug)]
|
|
enum SubCmds {
|
|
/// Initialize cull-gmail configuration, credentials, and OAuth2 tokens.
|
|
///
|
|
/// Sets up the complete cull-gmail environment including configuration directory,
|
|
/// OAuth2 credentials, default configuration files, and initial authentication flow.
|
|
#[clap(name = "init", display_order = 1)]
|
|
Init(InitCli),
|
|
|
|
/// Query, filter, and perform batch operations on Gmail messages.
|
|
///
|
|
/// Supports advanced Gmail query syntax, label filtering, and batch actions
|
|
/// including trash and permanent deletion with safety controls.
|
|
#[clap(name = "messages", display_order = 3, next_help_heading = "Labels")]
|
|
Message(MessagesCli),
|
|
|
|
/// List and inspect available Gmail labels.
|
|
///
|
|
/// Displays all labels in your Gmail account with their internal IDs,
|
|
/// useful for understanding label structure before creating queries or rules.
|
|
#[clap(name = "labels", display_order = 2, next_help_heading = "Rules")]
|
|
Labels(LabelsCli),
|
|
|
|
/// Configure and execute automated message retention rules.
|
|
///
|
|
/// Provides rule-based message lifecycle management with configurable
|
|
/// retention periods, label targeting, and automated actions.
|
|
#[clap(name = "rules", display_order = 2)]
|
|
Rules(RulesCli),
|
|
|
|
/// Export and import OAuth2 tokens for ephemeral environments.
|
|
///
|
|
/// Supports token export to compressed strings and automatic import from
|
|
/// environment variables for container deployments and CI/CD pipelines.
|
|
#[clap(name = "token", display_order = 4)]
|
|
Token(TokenCli),
|
|
}
|
|
|
|
/// CLI application entry point with comprehensive error handling and logging setup.
|
|
///
|
|
/// This function initializes the async runtime, parses command-line arguments,
|
|
/// configures logging based on user preferences, and orchestrates the main
|
|
/// application workflow with proper error handling and exit code management.
|
|
///
|
|
/// # Process Flow
|
|
///
|
|
/// 1. **Argument Parsing**: Parse command-line arguments using clap
|
|
/// 2. **Logging Setup**: Initialize logging with user-specified verbosity
|
|
/// 3. **Application Execution**: Run the main application logic
|
|
/// 4. **Error Handling**: Handle errors with detailed reporting
|
|
/// 5. **Exit Code**: Return appropriate exit codes for shell integration
|
|
///
|
|
/// # Exit Codes
|
|
///
|
|
/// - **0**: Successful execution
|
|
/// - **101**: Error occurred (details logged and printed to stderr)
|
|
///
|
|
/// # Error Reporting
|
|
///
|
|
/// Errors are reported through multiple channels:
|
|
/// - **Logging**: Structured error logging for debugging
|
|
/// - **stderr**: User-friendly error messages
|
|
/// - **Exit codes**: Shell-scriptable status reporting
|
|
#[tokio::main]
|
|
async fn main() {
|
|
let args = Cli::parse();
|
|
|
|
let mut logging = get_logging(args.logging.log_level_filter());
|
|
logging.init();
|
|
log::info!("Logging started.");
|
|
|
|
std::process::exit(match run(args).await {
|
|
Ok(_) => 0,
|
|
Err(e) => {
|
|
if let Some(src) = e.source() {
|
|
log::error!("{e}: {src}");
|
|
eprintln!("{e}: {src}");
|
|
} else {
|
|
log::error!("{e}");
|
|
eprintln!("{e}");
|
|
}
|
|
101
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Main application logic dispatcher handling subcommand execution and default behavior.
|
|
///
|
|
/// This function orchestrates the core application workflow by:
|
|
/// 1. Loading configuration from files and environment
|
|
/// 2. Initializing the Gmail API client with OAuth2 authentication
|
|
/// 3. Dispatching to appropriate subcommands or executing default rule processing
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `args` - Parsed command-line arguments containing global options and subcommands
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Returns `Result<()>` indicating success or failure of the operation.
|
|
///
|
|
/// # Default Behavior
|
|
///
|
|
/// When no subcommand is specified, the function executes the default rule processing
|
|
/// workflow, loading rules from configuration and executing them based on the
|
|
/// current execution mode setting.
|
|
///
|
|
/// # Error Handling
|
|
///
|
|
/// Errors can occur during:
|
|
/// - Configuration loading and parsing
|
|
/// - Gmail client initialization and authentication
|
|
/// - Subcommand execution
|
|
/// - Rule processing operations
|
|
async fn run(args: Cli) -> Result<()> {
|
|
let (config, client_config) = get_config()?;
|
|
|
|
// Check for token restoration before client initialization
|
|
restore_tokens_if_available(&config, &client_config)?;
|
|
|
|
let mut client = GmailClient::new_with_config(client_config).await?;
|
|
|
|
let Some(sub_command) = args.sub_command else {
|
|
let rules = rules_cli::get_rules()?;
|
|
let execute = config.get_bool("execute").unwrap_or(false);
|
|
return run_rules(&mut client, rules, execute).await;
|
|
};
|
|
|
|
match sub_command {
|
|
SubCmds::Init(init_cli) => {
|
|
// Init commands don't need a Gmail client since they set up the config
|
|
init_cli.run().await
|
|
}
|
|
SubCmds::Message(messages_cli) => messages_cli.run(&mut client).await,
|
|
SubCmds::Labels(labels_cli) => labels_cli.run(client).await,
|
|
SubCmds::Rules(rules_cli) => rules_cli.run(&mut client).await,
|
|
SubCmds::Token(token_cli) => {
|
|
// Token commands don't need an initialized client, just the config
|
|
// We need to get a fresh client_config since the original was moved
|
|
let (_, token_client_config) = get_config()?;
|
|
token_cli.run(&token_client_config).await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Creates and configures a logging builder with appropriate verbosity levels.
|
|
///
|
|
/// This function sets up structured logging for the application with:
|
|
/// - Minimum info-level logging for user-facing information
|
|
/// - Configurable verbosity based on command-line flags
|
|
/// - Timestamp formatting for operation tracking
|
|
/// - Focused logging on the cull-gmail crate to reduce noise
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `level` - Desired log level filter from command-line verbosity flags
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Returns a configured `env_logger::Builder` ready for initialization.
|
|
///
|
|
/// # Logging Levels
|
|
///
|
|
/// - **Error**: Critical failures and unrecoverable errors
|
|
/// - **Warn**: Non-fatal issues, dry-run notifications, missing resources
|
|
/// - **Info**: General operation progress, message counts, rule execution
|
|
/// - **Debug**: Detailed operation info, API calls, configuration values
|
|
/// - **Trace**: Very detailed debugging information
|
|
///
|
|
/// # Default Behavior
|
|
///
|
|
/// The function enforces a minimum of Info-level logging to ensure users
|
|
/// receive adequate feedback about application operations, even when
|
|
/// verbosity is not explicitly requested.
|
|
fn get_logging(level: log::LevelFilter) -> env_logger::Builder {
|
|
let level = if level > log::LevelFilter::Info {
|
|
level
|
|
} else {
|
|
log::LevelFilter::Info
|
|
};
|
|
|
|
let mut builder = env_logger::Builder::new();
|
|
|
|
builder.filter(Some("cull_gmail"), level);
|
|
// TODO: Provide an option to set wider filter allowing all crates to report
|
|
|
|
builder.format_timestamp_secs().format_module_path(false);
|
|
|
|
builder
|
|
}
|
|
|
|
/// Loads and parses application configuration from multiple sources.
|
|
///
|
|
/// This function implements a hierarchical configuration loading strategy:
|
|
/// 1. **Default values**: Sensible defaults for all configuration options
|
|
/// 2. **Configuration file**: User-specific settings from `~/.cull-gmail/cull-gmail.toml`
|
|
/// 3. **Environment variables**: Runtime overrides with `APP_` prefix
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Returns a tuple containing:
|
|
/// - **Config**: Raw configuration for general application settings
|
|
/// - **ClientConfig**: Processed Gmail client configuration with OAuth2 setup
|
|
///
|
|
/// # Configuration Hierarchy
|
|
///
|
|
/// Settings are applied in this order (later sources override earlier ones):
|
|
/// 1. Built-in defaults
|
|
/// 2. Configuration file values
|
|
/// 3. Environment variable overrides
|
|
///
|
|
/// # Configuration Parameters
|
|
///
|
|
/// ## Default Values:
|
|
/// - `credentials`: "credential.json" - OAuth2 credential file name
|
|
/// - `config_root`: "h:.cull-gmail" - Configuration directory (home-relative)
|
|
/// - `rules`: "rules.toml" - Rules configuration file name
|
|
/// - `execute`: true - Default execution mode (can be overridden for safety)
|
|
///
|
|
/// ## Environment Variables:
|
|
/// - `APP_CREDENTIALS`: Override credential file name
|
|
/// - `APP_CONFIG_ROOT`: Override configuration directory
|
|
/// - `APP_RULES`: Override rules file name
|
|
/// - `APP_EXECUTE`: Override execution mode (true/false)
|
|
///
|
|
/// # Error Handling
|
|
///
|
|
/// Configuration errors can occur due to:
|
|
/// - Missing or inaccessible configuration files
|
|
/// - Invalid TOML syntax in configuration files
|
|
/// - Missing OAuth2 credential files
|
|
/// - Invalid OAuth2 credential format or structure
|
|
fn get_config() -> Result<(Config, ClientConfig)> {
|
|
let home_dir = env::home_dir().unwrap();
|
|
let path = home_dir.join(".cull-gmail/cull-gmail.toml");
|
|
log::info!("Loading config from {}", path.display());
|
|
|
|
let configurations = config::Config::builder()
|
|
.set_default("credential_file", "credential.json")?
|
|
.set_default("config_root", "h:.cull-gmail")?
|
|
.set_default("rules", "rules.toml")?
|
|
.set_default("execute", true)?
|
|
.set_default("token_cache_env", "CULL_GMAIL_TOKEN_CACHE")?
|
|
.add_source(config::File::with_name(
|
|
path.to_path_buf().to_str().unwrap(),
|
|
))
|
|
.add_source(config::Environment::with_prefix("APP"))
|
|
.build()?;
|
|
|
|
Ok((
|
|
configurations.clone(),
|
|
ClientConfig::new_from_configuration(configurations)?,
|
|
))
|
|
}
|
|
|
|
/// Executes automated message retention rules across Gmail labels.
|
|
///
|
|
/// This function orchestrates the rule-based message processing workflow by:
|
|
/// 1. Organizing rules by their target labels
|
|
/// 2. Processing each label according to its configured rule
|
|
/// 3. Executing or simulating actions based on execution mode
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `client` - Mutable Gmail client for API operations
|
|
/// * `rules` - Loaded rules configuration containing all retention policies
|
|
/// * `execute` - Whether to actually perform actions (true) or dry-run (false)
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Returns `Result<()>` indicating success or failure of the rule processing.
|
|
///
|
|
/// # Rule Processing Flow
|
|
///
|
|
/// For each configured label:
|
|
/// 1. **Rule Lookup**: Find the retention rule for the label
|
|
/// 2. **Rule Application**: Apply rule criteria to find matching messages
|
|
/// 3. **Action Determination**: Determine appropriate action (trash/delete)
|
|
/// 4. **Execution**: Execute action or simulate for dry-run
|
|
///
|
|
/// # Safety Features
|
|
///
|
|
/// - **Dry-run mode**: When `execute` is false, actions are logged but not performed
|
|
/// - **Error isolation**: Errors for individual labels don't stop processing of other labels
|
|
/// - **Detailed logging**: Comprehensive logging of rule execution and results
|
|
///
|
|
/// # Error Handling
|
|
///
|
|
/// The function continues processing even if individual rules fail, logging
|
|
/// warnings for missing rules, processing errors, or action failures.
|
|
async fn run_rules(client: &mut GmailClient, rules: Rules, execute: bool) -> Result<()> {
|
|
let rules_by_labels = rules.get_rules_by_label();
|
|
|
|
for label in rules.labels() {
|
|
let Some(rule) = rules_by_labels.get(&label) else {
|
|
log::warn!("no rule found for label `{label}`");
|
|
continue;
|
|
};
|
|
|
|
log::info!("Executing rule `#{}` for label `{label}`", rule.describe());
|
|
client.set_rule(rule.clone());
|
|
client.set_execute(execute);
|
|
if let Err(e) = client.find_rule_and_messages_for_label(&label).await {
|
|
log::warn!("Nothing to process for label `{label}` as {e}");
|
|
continue;
|
|
}
|
|
let Some(action) = client.action() else {
|
|
log::warn!("no valid action specified for rule #{}", rule.id());
|
|
continue;
|
|
};
|
|
|
|
if execute {
|
|
execute_action(action, client, &label).await;
|
|
} else {
|
|
log::warn!("Execution stopped for dry run");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Restores OAuth2 tokens from environment variable if available.
|
|
///
|
|
/// This function checks if the token cache environment variable is set and,
|
|
/// if found, restores the token files before client initialization to enable
|
|
/// ephemeral environment workflows.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `config` - Application configuration containing token environment variable name
|
|
/// * `client_config` - Client configuration containing token persistence path
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Returns `Result<()>` indicating success or failure. Non-critical errors
|
|
/// (like missing environment variables) are logged but don't cause failure.
|
|
///
|
|
/// # Process
|
|
///
|
|
/// 1. **Check Environment**: Look for configured token cache environment variable
|
|
/// 2. **Skip if Missing**: Continue normally if environment variable not set
|
|
/// 3. **Restore Tokens**: Decode and restore token files if variable present
|
|
/// 4. **Log Results**: Report restoration success or failures
|
|
///
|
|
/// This function enables seamless token restoration for:
|
|
/// - Container deployments with injected token environment variables
|
|
/// - CI/CD pipelines with stored token secrets
|
|
/// - Ephemeral compute environments requiring periodic Gmail access
|
|
fn restore_tokens_if_available(config: &Config, client_config: &ClientConfig) -> Result<()> {
|
|
let token_env_var = config
|
|
.get_string("token_cache_env")
|
|
.unwrap_or_else(|_| "CULL_GMAIL_TOKEN_CACHE".to_string());
|
|
|
|
if let Ok(token_data) = env::var(&token_env_var) {
|
|
log::info!("Found {token_env_var} environment variable, restoring tokens");
|
|
restore_tokens_from_string(&token_data, client_config.persist_path())?;
|
|
log::info!("Tokens successfully restored from environment variable");
|
|
} else {
|
|
log::debug!(
|
|
"No {token_env_var} environment variable found, proceeding with normal token flow"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Executes the specified end-of-life action on messages for a Gmail label.
|
|
///
|
|
/// This function performs the actual message operations (trash or delete) based on
|
|
/// the rule configuration and execution mode. It handles both recoverable (trash)
|
|
/// and permanent (delete) operations with appropriate logging and error handling.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `action` - The end-of-life action to perform (Trash or Delete)
|
|
/// * `client` - Gmail client configured with messages to process
|
|
/// * `label` - Label name for context in logging and error reporting
|
|
///
|
|
/// # Actions
|
|
///
|
|
/// ## Trash
|
|
/// - **Operation**: Moves messages to Gmail's Trash folder
|
|
/// - **Reversibility**: Messages can be recovered from Trash for ~30 days
|
|
/// - **Safety**: Relatively safe operation with recovery options
|
|
///
|
|
/// ## Delete
|
|
/// - **Operation**: Permanently deletes messages from Gmail
|
|
/// - **Reversibility**: **IRREVERSIBLE** - messages cannot be recovered
|
|
/// - **Safety**: High-risk operation requiring careful consideration
|
|
///
|
|
/// # Error Handling
|
|
///
|
|
/// The function logs errors but does not propagate them, allowing rule processing
|
|
/// to continue for other labels even if one action fails. Errors are reported through:
|
|
/// - **Warning logs**: Structured logging for debugging
|
|
/// - **Label context**: Error messages include label name for traceability
|
|
///
|
|
/// # Safety Considerations
|
|
///
|
|
/// This function should only be called when execute mode is enabled and after
|
|
/// appropriate user confirmation for destructive operations.
|
|
async fn execute_action(action: EolAction, client: &GmailClient, label: &str) {
|
|
match action {
|
|
EolAction::Trash => {
|
|
log::info!("***executing trash messages***");
|
|
if client.batch_trash().await.is_err() {
|
|
log::warn!("Move to trash failed for label `{label}`");
|
|
}
|
|
}
|
|
EolAction::Delete => {
|
|
log::info!("***executing final delete messages***");
|
|
if client.batch_delete().await.is_err() {
|
|
log::warn!("Delete failed for label `{label}`");
|
|
}
|
|
}
|
|
}
|
|
}
|