Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
30 changes: 30 additions & 0 deletions cli/src/commands/activity/handler.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#[cfg(feature = "project")]
use super::args::{ActivityArgs, ActivityCommands};
#[cfg(feature = "project")]
use super::subcommands;
#[cfg(feature = "project")]
use crate::commands::common::CliResult;
use backlog_api_client::client::BacklogApiClient;

#[cfg(feature = "project")]
pub async fn execute(client: &BacklogApiClient, activity_args: ActivityArgs) -> CliResult<()> {
match activity_args.command {
ActivityCommands::Project {
project_id,
type_ids,
count,
order,
} => {
subcommands::recent::project_recent(client, project_id, type_ids, count, order).await?;
}
#[cfg(feature = "space")]
ActivityCommands::Space {
type_ids,
count,
order,
} => {
subcommands::recent::space_recent(client, type_ids, count, order).await?;
}
}
Ok(())
}
11 changes: 11 additions & 0 deletions cli/src/commands/activity/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#[cfg(feature = "project")]
pub mod args;
#[cfg(feature = "project")]
mod handler;
#[cfg(feature = "project")]
mod subcommands;

#[cfg(feature = "project")]
pub use args::ActivityArgs;
#[cfg(feature = "project")]
pub use handler::execute;
2 changes: 2 additions & 0 deletions cli/src/commands/activity/subcommands/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#[cfg(feature = "project")]
pub(crate) mod recent;
147 changes: 147 additions & 0 deletions cli/src/commands/activity/subcommands/recent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#[cfg(feature = "project")]
use crate::commands::common::{CliResult, truncate_text};
#[cfg(feature = "project")]
use backlog_api_client::ProjectIdOrKey;
use backlog_api_client::client::BacklogApiClient;
use backlog_core::activity::Activity;
#[cfg(any(feature = "project", feature = "space"))]
use backlog_core::identifier::{ActivityTypeId, Identifier};
#[cfg(feature = "project")]
use backlog_project::GetProjectRecentUpdatesParams;
#[cfg(feature = "space")]
use backlog_space::GetSpaceRecentUpdatesParams;

/// Helper function to print a single activity
fn print_activity(activity: &Activity) {
println!("---");
println!("ID: {}", activity.id.value());
println!("Type: {}", activity.type_id);
// Use helper method to access project name
let project_name = activity.project_name().unwrap_or("Unknown");
println!("Project: {project_name}");
println!("Created by: {}", activity.created_user.name);
println!(
"Created at: {}",
activity.created.format("%Y-%m-%d %H:%M:%S")
);

// Display content based on type
match &activity.content {
backlog_core::activity::Content::Standard {
summary,
description,
..
} => {
if let Some(summary) = summary {
println!("Summary: {summary}");
}
if let Some(description) = description {
let preview = truncate_text(description, 100);
println!("Description: {preview}");
}
}
backlog_core::activity::Content::UserManagement { users, .. } => {
if let Some(users) = users {
println!("Users involved: {}", users.len());
for user in users.iter().take(3) {
println!(" - {}", user.name);
}
if users.len() > 3 {
println!(" ... and {} more", users.len() - 3);
}
}
}
_ => {
// Other content types not yet implemented in CLI
println!("Activity type: {:?}", activity.type_id);
}
}
}

/// Helper function to print a list of activities
fn print_activities(activities: &[Activity]) {
if activities.is_empty() {
println!("No activities found.");
} else {
println!("Found {} activities:", activities.len());
for activity in activities {
print_activity(activity);
}
}
}

/// Helper function to parse comma-separated activity type IDs
#[cfg(any(feature = "project", feature = "space"))]
fn parse_type_ids(type_ids_str: &str) -> Result<Vec<ActivityTypeId>, String> {
type_ids_str
.split(',')
.map(|s| {
s.trim()
.parse::<u32>()
.map(ActivityTypeId::new)
.map_err(|e| format!("Failed to parse type_id '{}': {}", s.trim(), e))
})
.collect()
}

/// Get recent activities in a project
#[cfg(feature = "project")]
pub(crate) async fn project_recent(
client: &BacklogApiClient,
project_id: String,
type_ids: Option<String>,
count: Option<u32>,
order: Option<String>,
) -> CliResult<()> {
println!("Getting recent activities for project: {project_id}");

let proj_id_or_key = project_id.parse::<ProjectIdOrKey>()?;
let mut params = GetProjectRecentUpdatesParams::new(proj_id_or_key);

// Parse activity type IDs
if let Some(type_ids_str) = type_ids {
params.activity_type_ids = Some(parse_type_ids(&type_ids_str)?);
}

if let Some(count) = count {
params.count = Some(count);
}

if let Some(order) = order {
params.order = Some(order);
}

let activities = client.project().get_project_recent_updates(params).await?;
print_activities(&activities);
Ok(())
}

/// Get recent activities in the space
#[cfg(feature = "space")]
pub(crate) async fn space_recent(
client: &BacklogApiClient,
type_ids: Option<String>,
count: Option<u32>,
order: Option<String>,
) -> CliResult<()> {
println!("Getting recent activities for space");

let mut params = GetSpaceRecentUpdatesParams::default();

// Parse activity type IDs
if let Some(type_ids_str) = type_ids {
params.activity_type_ids = Some(parse_type_ids(&type_ids_str)?);
}

if let Some(count) = count {
params.count = Some(count);
}

if let Some(order) = order {
params.order = Some(order);
}

let activities = client.space().get_space_recent_updates(params).await?;
print_activities(&activities);
Ok(())
}
109 changes: 109 additions & 0 deletions cli/src/commands/common.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//! Common utilities and helpers for CLI commands
//!
//! This module provides reusable functions for:
//! - ID parsing (ProjectIdOrKey, IssueIdOrKey, comma-separated IDs)
//! - Date parsing and conversion
//! - Display helpers (truncate text, format bytes)
//! - File operations (download files)
//! - Error handling

use backlog_core::identifier::ProjectId;
use backlog_core::{ProjectIdOrKey, ProjectKey};
use chrono::{DateTime, NaiveDate, Utc};
use std::error::Error;

/// Type alias for CLI results
pub type CliResult<T = ()> = Result<T, Box<dyn Error>>;

/// Parse a string into ProjectIdOrKey
///
/// Tries to parse as u32 first (numeric ID), falls back to ProjectKey
pub fn parse_project_id_or_key(input: &str) -> CliResult<ProjectIdOrKey> {
if let Ok(id) = input.parse::<u32>() {
Ok(ProjectIdOrKey::from(ProjectId::new(id)))
} else {
let key = input
.parse::<ProjectKey>()
.map_err(|e| format!("Invalid project key '{}': {}", input, e))?;
Ok(ProjectIdOrKey::from(key))
}
}

/// Convert NaiveDate to start of day DateTime<Utc> (00:00:00)
pub fn date_to_start_of_day(date: NaiveDate) -> DateTime<Utc> {
date.and_hms_opt(0, 0, 0)
.expect("00:00:00 is always valid")
.and_utc()
}

/// Convert NaiveDate to end of day DateTime<Utc> (23:59:59)
pub fn date_to_end_of_day(date: NaiveDate) -> DateTime<Utc> {
date.and_hms_opt(23, 59, 59)
.expect("23:59:59 is always valid")
.and_utc()
}

/// Truncate text safely at UTF-8 boundary
///
/// If the text is longer than max_length, truncates it and adds "..."
pub fn truncate_text(text: &str, max_length: usize) -> String {
if text.len() <= max_length {
text.to_string()
} else {
let mut end = max_length;
while !text.is_char_boundary(end) && end > 0 {
end -= 1;
}
format!("{}...", &text[..end])
}
}

/// Format bytes in human-readable form (B, KB, MB, GB, TB)
pub fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit_index = 0;

while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}

if unit_index == 0 {
format!("{} {}", size as u64, UNITS[unit_index])
} else {
format!("{:.1} {}", size, UNITS[unit_index])
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_truncate_text() {
assert_eq!(truncate_text("Hello", 10), "Hello");
assert_eq!(truncate_text("Hello World", 5), "Hello...");
assert_eq!(truncate_text("こんにちは世界", 9), "こんに...");
}

#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(1023), "1023 B");
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1536), "1.5 KB");
assert_eq!(format_bytes(1048576), "1.0 MB");
assert_eq!(format_bytes(1073741824), "1.0 GB");
}

#[test]
fn test_date_conversions() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let start = date_to_start_of_day(date);
let end = date_to_end_of_day(date);

assert_eq!(start.format("%H:%M:%S").to_string(), "00:00:00");
assert_eq!(end.format("%H:%M:%S").to_string(), "23:59:59");
}
}
Loading