things3_cli/lib.rs
1//! Things CLI library
2//! This module provides real-time updates and progress tracking capabilities
3
4pub mod bulk_operations;
5
6#[cfg(feature = "observability")]
7pub mod dashboard;
8
9pub mod events;
10
11#[cfg(feature = "observability")]
12pub mod health;
13
14pub mod logging;
15
16#[cfg(feature = "mcp-server")]
17pub mod mcp;
18
19#[cfg(feature = "observability")]
20pub mod metrics;
21
22#[cfg(feature = "observability")]
23pub mod monitoring;
24
25pub mod progress;
26// pub mod thread_safe_db; // Removed - ThingsDatabase is now Send + Sync
27pub mod websocket;
28
29use crate::events::EventBroadcaster;
30use crate::websocket::WebSocketServer;
31use clap::{Parser, Subcommand};
32use std::io::Write;
33use std::path::PathBuf;
34use std::sync::Arc;
35use things3_core::{Result, ThingsDatabase};
36
37#[derive(Parser, Debug)]
38#[command(name = "things3")]
39#[command(about = "Things 3 CLI with integrated MCP server")]
40#[command(version)]
41pub struct Cli {
42 /// Database path (defaults to Things 3 default location)
43 #[arg(long, short)]
44 pub database: Option<PathBuf>,
45
46 /// Fall back to default database path if specified path doesn't exist
47 #[arg(long)]
48 pub fallback_to_default: bool,
49
50 /// Verbose output
51 #[arg(long, short)]
52 pub verbose: bool,
53
54 /// Use the deprecated direct-SQLite mutation backend instead of AppleScript.
55 ///
56 /// CulturedCode warns against direct database writes
57 /// (https://culturedcode.com/things/support/articles/5510170/). This flag
58 /// re-enables the deprecated SqlxBackend and is required to use
59 /// `restore_database`. Will be removed in a future release.
60 #[arg(long, env = "THINGS_UNSAFE_DIRECT_DB")]
61 pub unsafe_direct_db: bool,
62
63 #[command(subcommand)]
64 pub command: Commands,
65}
66
67#[derive(Subcommand, Debug, PartialEq, Eq)]
68pub enum Commands {
69 /// Get inbox tasks
70 Inbox {
71 /// Limit number of results
72 #[arg(long, short)]
73 limit: Option<usize>,
74 },
75 /// Get today's tasks
76 Today {
77 /// Limit number of results
78 #[arg(long, short)]
79 limit: Option<usize>,
80 },
81 /// Get projects
82 Projects {
83 /// Filter by area UUID
84 #[arg(long)]
85 area: Option<String>,
86 /// Limit number of results
87 #[arg(long, short)]
88 limit: Option<usize>,
89 },
90 /// Get areas
91 Areas {
92 /// Limit number of results
93 #[arg(long, short)]
94 limit: Option<usize>,
95 },
96 /// Search tasks
97 Search {
98 /// Search query
99 query: String,
100 /// Limit number of results
101 #[arg(long, short)]
102 limit: Option<usize>,
103 },
104 /// Start MCP server mode
105 #[cfg(feature = "mcp-server")]
106 Mcp,
107 /// Health check
108 Health,
109 /// Start health check server
110 #[cfg(feature = "observability")]
111 HealthServer {
112 /// Port to listen on
113 #[arg(long, short, default_value = "8080")]
114 port: u16,
115 },
116 /// Start monitoring dashboard
117 #[cfg(feature = "observability")]
118 Dashboard {
119 /// Port to listen on
120 #[arg(long, short, default_value = "3000")]
121 port: u16,
122 },
123 /// Start WebSocket server for real-time updates
124 Server {
125 /// Port to listen on
126 #[arg(long, short, default_value = "8080")]
127 port: u16,
128 },
129 /// Watch for real-time updates
130 Watch {
131 /// WebSocket server URL
132 #[arg(long, short, default_value = "ws://127.0.0.1:8080")]
133 url: String,
134 },
135 /// Validate real-time features health
136 Validate,
137 /// Bulk operations with progress tracking
138 Bulk {
139 #[command(subcommand)]
140 operation: BulkOperation,
141 },
142}
143
144#[derive(Subcommand, Debug, PartialEq, Eq)]
145pub enum BulkOperation {
146 /// Export all tasks with progress tracking
147 Export {
148 /// Export format (json, csv, xml)
149 #[arg(long, short, default_value = "json")]
150 format: String,
151 },
152 /// Update multiple tasks status
153 UpdateStatus {
154 /// Task IDs to update (comma-separated)
155 task_ids: String,
156 /// New status (completed, cancelled, trashed, incomplete)
157 status: String,
158 },
159 /// Search and process tasks
160 SearchAndProcess {
161 /// Search query
162 query: String,
163 },
164}
165
166/// Print tasks to the given writer
167///
168/// # Examples
169///
170/// ```no_run
171/// use things3_cli::print_tasks;
172/// use things3_core::ThingsDatabase;
173/// use std::io;
174///
175/// # async fn example() -> things3_core::Result<()> {
176/// let db = ThingsDatabase::new(std::path::Path::new("test.db")).await?;
177/// let tasks = db.get_inbox(Some(10)).await?;
178/// print_tasks(&db, &tasks, &mut io::stdout())?;
179/// # Ok(())
180/// # }
181/// ```
182///
183/// # Errors
184/// Returns an error if writing fails
185pub fn print_tasks<W: Write>(
186 _db: &ThingsDatabase,
187 tasks: &[things3_core::Task],
188 writer: &mut W,
189) -> Result<()> {
190 if tasks.is_empty() {
191 writeln!(writer, "No tasks found")?;
192 return Ok(());
193 }
194
195 writeln!(writer, "Found {} tasks:", tasks.len())?;
196 for task in tasks {
197 writeln!(writer, " • {} ({:?})", task.title, task.task_type)?;
198 if let Some(notes) = &task.notes {
199 writeln!(writer, " Notes: {notes}")?;
200 }
201 if let Some(deadline) = &task.deadline {
202 writeln!(writer, " Deadline: {deadline}")?;
203 }
204 if !task.tags.is_empty() {
205 writeln!(writer, " Tags: {}", task.tags.join(", "))?;
206 }
207 writeln!(writer)?;
208 }
209 Ok(())
210}
211
212/// Print projects to the given writer
213///
214/// # Examples
215///
216/// ```no_run
217/// use things3_cli::print_projects;
218/// use things3_core::ThingsDatabase;
219/// use std::io;
220///
221/// # async fn example() -> things3_core::Result<()> {
222/// let db = ThingsDatabase::new(std::path::Path::new("test.db")).await?;
223/// let projects = db.get_projects(None).await?;
224/// print_projects(&db, &projects, &mut io::stdout())?;
225/// # Ok(())
226/// # }
227/// ```
228///
229/// # Errors
230/// Returns an error if writing fails
231pub fn print_projects<W: Write>(
232 _db: &ThingsDatabase,
233 projects: &[things3_core::Project],
234 writer: &mut W,
235) -> Result<()> {
236 if projects.is_empty() {
237 writeln!(writer, "No projects found")?;
238 return Ok(());
239 }
240
241 writeln!(writer, "Found {} projects:", projects.len())?;
242 for project in projects {
243 writeln!(writer, " • {} ({:?})", project.title, project.status)?;
244 if let Some(notes) = &project.notes {
245 writeln!(writer, " Notes: {notes}")?;
246 }
247 if let Some(deadline) = &project.deadline {
248 writeln!(writer, " Deadline: {deadline}")?;
249 }
250 if !project.tags.is_empty() {
251 writeln!(writer, " Tags: {}", project.tags.join(", "))?;
252 }
253 writeln!(writer)?;
254 }
255 Ok(())
256}
257
258/// Print areas to the given writer
259///
260/// # Examples
261///
262/// ```no_run
263/// use things3_cli::print_areas;
264/// use things3_core::ThingsDatabase;
265/// use std::io;
266///
267/// # async fn example() -> things3_core::Result<()> {
268/// let db = ThingsDatabase::new(std::path::Path::new("test.db")).await?;
269/// let areas = db.get_areas().await?;
270/// print_areas(&db, &areas, &mut io::stdout())?;
271/// # Ok(())
272/// # }
273/// ```
274///
275/// # Errors
276/// Returns an error if writing fails
277pub fn print_areas<W: Write>(
278 _db: &ThingsDatabase,
279 areas: &[things3_core::Area],
280 writer: &mut W,
281) -> Result<()> {
282 if areas.is_empty() {
283 writeln!(writer, "No areas found")?;
284 return Ok(());
285 }
286
287 writeln!(writer, "Found {} areas:", areas.len())?;
288 for area in areas {
289 writeln!(writer, " • {}", area.title)?;
290 if let Some(notes) = &area.notes {
291 writeln!(writer, " Notes: {notes}")?;
292 }
293 if !area.tags.is_empty() {
294 writeln!(writer, " Tags: {}", area.tags.join(", "))?;
295 }
296 writeln!(writer)?;
297 }
298 Ok(())
299}
300
301/// Perform a health check on the database
302///
303/// # Examples
304///
305/// ```no_run
306/// use things3_cli::health_check;
307/// use things3_core::ThingsDatabase;
308///
309/// # async fn example() -> things3_core::Result<()> {
310/// let db = ThingsDatabase::new(std::path::Path::new("test.db")).await?;
311/// health_check(&db).await?;
312/// # Ok(())
313/// # }
314/// ```
315///
316/// # Errors
317/// Returns an error if the database is not accessible
318pub async fn health_check(db: &ThingsDatabase) -> Result<()> {
319 println!("🔍 Checking Things 3 database connection...");
320
321 // Check if database is connected
322 if !db.is_connected().await {
323 return Err(things3_core::ThingsError::unknown(
324 "Database is not connected".to_string(),
325 ));
326 }
327
328 // Get database statistics
329 let stats = db.get_stats().await?;
330 println!("✅ Database connection successful!");
331 println!(
332 " Found {} tasks, {} projects, {} areas",
333 stats.task_count, stats.project_count, stats.area_count
334 );
335
336 println!("🎉 All systems operational!");
337 Ok(())
338}
339
340// Temporarily disabled during SQLx migration
341// /// Start the MCP server
342// ///
343// /// # Errors
344// /// Returns an error if the server fails to start
345// pub fn start_mcp_server(db: Arc<SqlxThingsDatabase>, config: ThingsConfig) -> Result<()> {
346// println!("🚀 Starting MCP server...");
347// println!("🚧 MCP server is temporarily disabled during SQLx migration");
348// Err(things3_core::ThingsError::unknown("MCP server temporarily disabled".to_string()))
349// }
350
351/// Start the WebSocket server for real-time updates
352///
353/// # Examples
354///
355/// ```no_run
356/// use things3_cli::start_websocket_server;
357///
358/// # async fn example() -> things3_core::Result<()> {
359/// // Start WebSocket server on port 8080
360/// start_websocket_server(8080).await?;
361/// # Ok(())
362/// # }
363/// ```
364///
365/// # Errors
366/// Returns an error if the server fails to start
367pub async fn start_websocket_server(port: u16) -> Result<()> {
368 println!("🚀 Starting WebSocket server on port {port}...");
369
370 let server = WebSocketServer::new(port);
371 let _event_broadcaster = Arc::new(EventBroadcaster::new());
372
373 // Start the server
374 server
375 .start()
376 .await
377 .map_err(|e| things3_core::ThingsError::unknown(e.to_string()))?;
378
379 Ok(())
380}
381
382/// Watch for real-time updates via WebSocket
383///
384/// # Examples
385///
386/// ```
387/// use things3_cli::watch_updates;
388///
389/// # fn example() -> things3_core::Result<()> {
390/// // Connect to WebSocket server
391/// watch_updates("ws://127.0.0.1:8080")?;
392/// # Ok(())
393/// # }
394/// ```
395///
396/// # Errors
397/// Returns an error if the connection fails
398pub fn watch_updates(url: &str) -> Result<()> {
399 println!("👀 Connecting to WebSocket server at {url}...");
400
401 // In a real implementation, this would connect to the WebSocket server
402 // For now, we'll just print that it would connect
403 println!("✅ Would connect to WebSocket server");
404 println!(" (This is a placeholder - actual WebSocket client implementation would go here)");
405
406 Ok(())
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use things3_core::test_utils::create_test_database;
413 use tokio::runtime::Runtime;
414
415 #[test]
416 fn test_health_check() {
417 let temp_file = tempfile::NamedTempFile::new().unwrap();
418 let db_path = temp_file.path();
419 let rt = Runtime::new().unwrap();
420 rt.block_on(async { create_test_database(db_path).await.unwrap() });
421 let db = rt.block_on(async { ThingsDatabase::new(db_path).await.unwrap() });
422 let result = rt.block_on(async { health_check(&db).await });
423 assert!(result.is_ok());
424 }
425
426 #[tokio::test]
427 #[cfg(feature = "mcp-server")]
428 async fn test_start_mcp_server() {
429 let temp_file = tempfile::NamedTempFile::new().unwrap();
430 let db_path = temp_file.path();
431 create_test_database(db_path).await.unwrap();
432 let db = ThingsDatabase::new(db_path).await.unwrap();
433 let config = things3_core::ThingsConfig::default();
434
435 // Note: We can't actually run start_mcp_server in a test because it's an infinite
436 // loop that reads from stdin. Instead, we verify the server can be created.
437 let _server = crate::mcp::ThingsMcpServer::new(db.into(), config, true);
438 // Server created successfully
439 }
440
441 #[test]
442 fn test_start_websocket_server_function_exists() {
443 // Test that the function exists and can be referenced
444 // We don't actually call it as it would hang
445 // Test that function exists and can be referenced
446 // Function reference test passed if we get here
447 }
448
449 #[test]
450 fn test_watch_updates() {
451 let result = watch_updates("ws://127.0.0.1:8080");
452 assert!(result.is_ok());
453 }
454}