claude_code_acp/
cli.rs

1//! Command-line interface definitions
2//!
3//! Provides CLI argument parsing using clap for the Claude Code ACP Agent.
4
5use std::path::PathBuf;
6
7use clap::Parser;
8
9/// Claude Code ACP Agent (Rust) - Use Claude Code from any ACP client
10#[derive(Parser, Debug, Clone)]
11#[command(name = "claude-code-acp-rs")]
12#[command(version, about, long_about = None)]
13pub struct Cli {
14    /// Run in ACP server mode (stdio communication with editor)
15    #[arg(long)]
16    pub acp: bool,
17
18    /// Execute prompt directly in headless mode
19    #[arg(short = 'p', long, value_name = "PROMPT")]
20    pub prompt: Option<String>,
21
22    /// Enable diagnostic mode (auto-log to temp file)
23    #[arg(short, long)]
24    pub diagnostic: bool,
25
26    /// Log directory (implies diagnostic mode)
27    #[arg(short = 'l', long, value_name = "DIR")]
28    pub log_dir: Option<PathBuf>,
29
30    /// Log file name (implies diagnostic mode)
31    #[arg(short = 'f', long, value_name = "FILE")]
32    pub log_file: Option<String>,
33
34    /// Increase logging verbosity (-v, -vv, -vvv)
35    /// Note: RUST_LOG env var takes priority over this flag
36    #[arg(short, long, action = clap::ArgAction::Count)]
37    pub verbose: u8,
38
39    /// Quiet mode (only errors)
40    /// Note: RUST_LOG env var takes priority over this flag
41    #[arg(short, long)]
42    pub quiet: bool,
43
44    /// OpenTelemetry OTLP endpoint (e.g., http://localhost:4317)
45    /// When otel feature is enabled, this configures the OTLP exporter.
46    /// When otel feature is disabled, this argument is accepted but ignored.
47    #[arg(long, value_name = "URL", env = "OTEL_EXPORTER_OTLP_ENDPOINT")]
48    pub otel_endpoint: Option<String>,
49
50    /// OpenTelemetry service name
51    #[arg(long, value_name = "NAME", default_value = "claude-code-acp-rs")]
52    pub otel_service_name: String,
53}
54
55#[allow(clippy::derivable_impls)]
56impl Default for Cli {
57    fn default() -> Self {
58        Self {
59            acp: false,
60            prompt: None,
61            diagnostic: false,
62            log_dir: None,
63            log_file: None,
64            verbose: 0,
65            quiet: false,
66            otel_endpoint: None,
67            otel_service_name: "claude-code-acp-rs".to_string(),
68        }
69    }
70}
71
72impl Cli {
73    /// Check if diagnostic mode is enabled (output to file)
74    ///
75    /// Returns true if `--diagnostic` is set, or if `--log-dir` or `--log-file` is specified.
76    pub fn is_diagnostic(&self) -> bool {
77        self.diagnostic || self.log_dir.is_some() || self.log_file.is_some()
78    }
79
80    /// Check if OpenTelemetry tracing is enabled
81    ///
82    /// Returns true if `--otel-endpoint` is specified and the otel feature is enabled.
83    #[cfg(feature = "otel")]
84    pub fn is_otel_enabled(&self) -> bool {
85        self.otel_endpoint.is_some()
86    }
87
88    /// Check if OpenTelemetry tracing is enabled (always false without otel feature)
89    /// Note: --otel-endpoint argument is still accepted but ignored when feature is disabled
90    #[cfg(not(feature = "otel"))]
91    pub fn is_otel_enabled(&self) -> bool {
92        if self.otel_endpoint.is_some() {
93            tracing::warn!("--otel-endpoint specified but otel feature is not enabled, ignoring");
94        }
95        false
96    }
97
98    /// Get the log level based on CLI arguments
99    ///
100    /// - `--quiet`: ERROR
101    /// - default: INFO
102    /// - `-v`: DEBUG
103    /// - `-vv` or more: TRACE
104    pub fn log_level(&self) -> tracing::Level {
105        if self.quiet {
106            tracing::Level::ERROR
107        } else {
108            match self.verbose {
109                0 => tracing::Level::INFO,
110                1 => tracing::Level::DEBUG,
111                _ => tracing::Level::TRACE,
112            }
113        }
114    }
115
116    /// Get the log file path for diagnostic mode
117    ///
118    /// Uses the specified log directory and file name, or defaults to:
119    /// - Directory: system temp directory
120    /// - File: `claude-code-acp-rs-YYYYMMDD-HHMMSS.log`
121    pub fn log_path(&self) -> PathBuf {
122        let dir = self.log_dir.clone().unwrap_or_else(std::env::temp_dir);
123
124        let filename = self.log_file.clone().unwrap_or_else(|| {
125            let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
126            format!("claude-code-acp-rs-{timestamp}.log")
127        });
128
129        dir.join(filename)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_default_cli() {
139        let cli = Cli::default();
140        assert!(!cli.is_diagnostic());
141        assert_eq!(cli.log_level(), tracing::Level::INFO);
142    }
143
144    #[test]
145    fn test_diagnostic_mode() {
146        let cli = Cli {
147            diagnostic: true,
148            ..Default::default()
149        };
150        assert!(cli.is_diagnostic());
151    }
152
153    #[test]
154    fn test_log_dir_implies_diagnostic() {
155        let cli = Cli {
156            log_dir: Some(PathBuf::from("/tmp")),
157            ..Default::default()
158        };
159        assert!(cli.is_diagnostic());
160    }
161
162    #[test]
163    fn test_log_file_implies_diagnostic() {
164        let cli = Cli {
165            log_file: Some("test.log".to_string()),
166            ..Default::default()
167        };
168        assert!(cli.is_diagnostic());
169    }
170
171    #[test]
172    fn test_log_levels() {
173        // Quiet mode
174        let cli = Cli {
175            quiet: true,
176            ..Default::default()
177        };
178        assert_eq!(cli.log_level(), tracing::Level::ERROR);
179
180        // Default
181        let cli = Cli::default();
182        assert_eq!(cli.log_level(), tracing::Level::INFO);
183
184        // Verbose
185        let cli = Cli {
186            verbose: 1,
187            ..Default::default()
188        };
189        assert_eq!(cli.log_level(), tracing::Level::DEBUG);
190
191        // Very verbose
192        let cli = Cli {
193            verbose: 2,
194            ..Default::default()
195        };
196        assert_eq!(cli.log_level(), tracing::Level::TRACE);
197    }
198
199    #[test]
200    fn test_log_path_custom_dir() {
201        let cli = Cli {
202            log_dir: Some(PathBuf::from("/var/log")),
203            log_file: Some("test.log".to_string()),
204            ..Default::default()
205        };
206        assert_eq!(cli.log_path(), PathBuf::from("/var/log/test.log"));
207    }
208
209    #[test]
210    fn test_log_path_default_generates_timestamp() {
211        let cli = Cli::default();
212        let path = cli.log_path();
213
214        // Should be in temp directory
215        assert!(path.starts_with(std::env::temp_dir()));
216
217        // Should have correct prefix
218        let filename = path.file_name().unwrap().to_str().unwrap();
219        assert!(filename.starts_with("claude-code-acp-rs-"));
220        assert!(
221            std::path::Path::new(filename)
222                .extension()
223                .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
224        );
225    }
226
227    #[test]
228    fn test_cli_acp_mode() {
229        let cli = Cli::parse_from(["claude-code-acp-rs", "--acp"]);
230        assert!(cli.acp);
231        assert!(cli.prompt.is_none());
232    }
233
234    #[test]
235    fn test_cli_headless_mode() {
236        let cli = Cli::parse_from(["claude-code-acp-rs", "-p", "test prompt"]);
237        assert!(!cli.acp);
238        assert_eq!(cli.prompt, Some("test prompt".to_string()));
239    }
240
241    #[test]
242    fn test_cli_headless_mode_long() {
243        let cli = Cli::parse_from(["claude-code-acp-rs", "--prompt", "test prompt"]);
244        assert!(!cli.acp);
245        assert_eq!(cli.prompt, Some("test prompt".to_string()));
246    }
247
248    #[test]
249    fn test_cli_no_mode() {
250        let cli = Cli::parse_from(["claude-code-acp-rs"]);
251        assert!(!cli.acp);
252        assert!(cli.prompt.is_none());
253    }
254}