Skip to main content

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}