Skip to main content

things3_cli/
lib.rs

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