miyabi_cli/
service.rs

1//! Command service trait layer
2//!
3//! This module defines the core trait for all CLI commands, enabling:
4//! - Consistent JSON output across all commands
5//! - Reusable command logic for agents and other entry points
6//! - Testable command execution with structured outputs
7
8use crate::error::Result;
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use std::fmt::Debug;
12
13/// Command service trait for all CLI commands
14///
15/// This trait provides a unified interface for command execution with support for:
16/// - Type-safe structured outputs
17/// - JSON serialization
18/// - Async execution
19/// - Error handling
20///
21/// # Example
22///
23/// ```rust
24/// use miyabi_cli::service::CommandService;
25/// use miyabi_cli::error::Result;
26/// use async_trait::async_trait;
27/// use serde::Serialize;
28///
29/// #[derive(Debug, Serialize)]
30/// pub struct StatusOutput {
31///     pub is_installed: bool,
32///     pub git_branch: Option<String>,
33/// }
34///
35/// pub struct StatusCommand {
36///     pub watch: bool,
37/// }
38///
39/// #[async_trait]
40/// impl CommandService for StatusCommand {
41///     type Output = StatusOutput;
42///
43///     async fn execute(&self) -> Result<Self::Output> {
44///         // Command implementation
45///         Ok(StatusOutput {
46///             is_installed: true,
47///             git_branch: Some("main".to_string()),
48///         })
49///     }
50/// }
51/// ```
52#[async_trait]
53#[allow(dead_code)]
54pub trait CommandService: Send + Sync {
55    /// Output type for this command
56    ///
57    /// Must implement Serialize for JSON output and Debug for error reporting
58    type Output: Serialize + Debug + Send;
59
60    /// Execute the command and return structured output
61    ///
62    /// This method performs the core command logic and returns a structured result
63    /// that can be serialized to JSON or displayed to the user.
64    async fn execute(&self) -> Result<Self::Output>;
65
66    /// Convert output to JSON string
67    ///
68    /// Default implementation uses serde_json::to_string_pretty
69    fn to_json(&self, output: &Self::Output) -> Result<String> {
70        serde_json::to_string_pretty(output).map_err(|e| {
71            crate::error::CliError::Serialization(format!("Failed to serialize output: {}", e))
72        })
73    }
74
75    /// Execute command and optionally output as JSON
76    ///
77    /// This is a convenience method that combines execute() and to_json()
78    async fn execute_with_json(&self, json: bool) -> Result<String> {
79        let output = self.execute().await?;
80
81        if json {
82            self.to_json(&output)
83        } else {
84            // For non-JSON output, return debug representation
85            Ok(format!("{:?}", output))
86        }
87    }
88}
89
90/// Command output metadata
91///
92/// Common metadata fields that can be included in any command output
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[allow(dead_code)]
95pub struct CommandMetadata {
96    /// Command name
97    pub command: String,
98    /// Execution timestamp (ISO 8601)
99    pub timestamp: String,
100    /// Success status
101    pub success: bool,
102    /// Optional error message
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub error: Option<String>,
105}
106
107#[allow(dead_code)]
108impl CommandMetadata {
109    /// Create new command metadata
110    pub fn new(command: impl Into<String>, success: bool) -> Self {
111        Self {
112            command: command.into(),
113            timestamp: chrono::Utc::now().to_rfc3339(),
114            success,
115            error: None,
116        }
117    }
118
119    /// Create metadata with error
120    pub fn with_error(command: impl Into<String>, error: impl Into<String>) -> Self {
121        Self {
122            command: command.into(),
123            timestamp: chrono::Utc::now().to_rfc3339(),
124            success: false,
125            error: Some(error.into()),
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[derive(Debug, Serialize)]
135    struct TestOutput {
136        message: String,
137    }
138
139    struct TestCommand;
140
141    #[async_trait]
142    impl CommandService for TestCommand {
143        type Output = TestOutput;
144
145        async fn execute(&self) -> Result<Self::Output> {
146            Ok(TestOutput {
147                message: "test".to_string(),
148            })
149        }
150    }
151
152    #[tokio::test]
153    async fn test_command_service_execute() {
154        let cmd = TestCommand;
155        let output = cmd.execute().await.unwrap();
156        assert_eq!(output.message, "test");
157    }
158
159    #[tokio::test]
160    async fn test_command_service_to_json() {
161        let cmd = TestCommand;
162        let output = cmd.execute().await.unwrap();
163        let json = cmd.to_json(&output).unwrap();
164        assert!(json.contains("\"message\""));
165        assert!(json.contains("\"test\""));
166    }
167
168    #[tokio::test]
169    async fn test_command_service_execute_with_json() {
170        let cmd = TestCommand;
171        let json_output = cmd.execute_with_json(true).await.unwrap();
172        assert!(json_output.contains("\"message\""));
173    }
174
175    #[test]
176    fn test_command_metadata_creation() {
177        let metadata = CommandMetadata::new("test", true);
178        assert_eq!(metadata.command, "test");
179        assert!(metadata.success);
180        assert!(metadata.error.is_none());
181    }
182
183    #[test]
184    fn test_command_metadata_with_error() {
185        let metadata = CommandMetadata::with_error("test", "test error");
186        assert_eq!(metadata.command, "test");
187        assert!(!metadata.success);
188        assert_eq!(metadata.error, Some("test error".to_string()));
189    }
190}