Skip to main content

tldr_cli/commands/daemon/
daemon.rs

1//! Core daemon state machine and runtime
2//!
3//! This module contains the `TLDRDaemon` struct which manages:
4//! - Daemon lifecycle state (Initializing -> Ready -> ShuttingDown)
5//! - Salsa-style query cache
6//! - Session statistics tracking
7//! - Hook activity tracking
8//! - Dirty file tracking for incremental re-indexing
9//!
10//! # Security Mitigations
11//!
12//! - TIGER-P2-02: Socket cleanup on abnormal exit via signal handlers
13
14use std::collections::hash_map::DefaultHasher;
15use std::collections::{HashMap, HashSet};
16use std::hash::{Hash, Hasher};
17use std::path::PathBuf;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::Arc;
20use std::time::Instant;
21
22use dashmap::DashMap;
23use tokio::sync::{watch, RwLock};
24
25use super::error::{DaemonError, DaemonResult};
26use super::ipc::{read_command, send_response, IpcListener, IpcStream};
27use super::salsa::{QueryCache, QueryKey};
28use super::types::{
29    AllSessionsSummary, DaemonCommand, DaemonConfig, DaemonResponse, DaemonStatus, HookStats,
30    SalsaCacheStats, SessionStats, HOOK_FLUSH_THRESHOLD,
31};
32
33#[cfg(test)]
34use super::types::DEFAULT_REINDEX_THRESHOLD;
35#[cfg(feature = "semantic")]
36use tldr_core::semantic::{BuildOptions, CacheConfig, IndexSearchOptions, SemanticIndex};
37use tldr_core::{
38    architecture_analysis, build_project_call_graph, change_impact, collect_all_functions,
39    dead_code_analysis, detect_or_parse_language, extract_file, find_importers, get_cfg_context,
40    get_code_structure, get_dfg_context, get_file_tree, get_imports, get_relevant_context,
41    get_slice, impact_analysis, search as tldr_search, FileTree, Language, NodeType,
42    SliceDirection,
43};
44
45// =============================================================================
46// Helper Functions
47// =============================================================================
48
49/// Hash a slice of string arguments into a u64 for cache key generation.
50fn hash_str_args(parts: &[&str]) -> u64 {
51    let mut hasher = DefaultHasher::new();
52    for part in parts {
53        part.hash(&mut hasher);
54    }
55    hasher.finish()
56}
57
58/// Count the number of file nodes in a FileTree recursively.
59fn count_tree_files(tree: &FileTree) -> usize {
60    match tree.node_type {
61        NodeType::File => 1,
62        NodeType::Dir => tree.children.iter().map(count_tree_files).sum(),
63    }
64}
65
66// =============================================================================
67// TLDRDaemon - Main Daemon Process
68// =============================================================================
69
70/// Main daemon process that handles client connections and manages state.
71///
72/// The daemon runs an event loop that:
73/// 1. Accepts incoming IPC connections
74/// 2. Reads commands from clients
75/// 3. Dispatches commands to handlers
76/// 4. Sends responses back to clients
77/// 5. Handles shutdown signals gracefully
78pub struct TLDRDaemon {
79    /// Project root directory
80    project: PathBuf,
81    /// Daemon configuration
82    config: DaemonConfig,
83    /// When the daemon was started
84    start_time: Instant,
85    /// Current daemon status
86    status: Arc<RwLock<DaemonStatus>>,
87    /// Salsa-style query cache
88    cache: QueryCache,
89    /// Per-session statistics
90    sessions: DashMap<String, SessionStats>,
91    /// Per-hook activity statistics
92    hooks: DashMap<String, HookStats>,
93    /// Set of dirty files awaiting reindex
94    dirty_files: Arc<RwLock<HashSet<PathBuf>>>,
95    /// Shutdown signal sender
96    shutdown_tx: watch::Sender<bool>,
97    /// Flag to track if we've been signaled to stop
98    stopping: AtomicBool,
99    /// Last time a client command was handled (for idle timeout)
100    last_activity: Arc<RwLock<Instant>>,
101    /// Number of indexed files (for status reporting)
102    indexed_files: Arc<RwLock<usize>>,
103    /// Persistent semantic index (built lazily on first query, invalidated on Notify)
104    #[cfg(feature = "semantic")]
105    semantic_index: Arc<RwLock<Option<SemanticIndex>>>,
106}
107
108impl TLDRDaemon {
109    /// Create a new daemon instance.
110    ///
111    /// The daemon starts in `Initializing` status and must have `run()` called
112    /// to begin accepting connections.
113    pub fn new(project: PathBuf, config: DaemonConfig) -> Self {
114        let (shutdown_tx, _shutdown_rx) = watch::channel(false);
115
116        Self {
117            project,
118            config,
119            start_time: Instant::now(),
120            status: Arc::new(RwLock::new(DaemonStatus::Initializing)),
121            cache: QueryCache::with_defaults(),
122            sessions: DashMap::new(),
123            hooks: DashMap::new(),
124            dirty_files: Arc::new(RwLock::new(HashSet::new())),
125            shutdown_tx,
126            stopping: AtomicBool::new(false),
127            last_activity: Arc::new(RwLock::new(Instant::now())),
128            indexed_files: Arc::new(RwLock::new(0)),
129            #[cfg(feature = "semantic")]
130            semantic_index: Arc::new(RwLock::new(None)),
131        }
132    }
133
134    /// Get the daemon's current status.
135    pub async fn status(&self) -> DaemonStatus {
136        *self.status.read().await
137    }
138
139    /// Get the daemon's uptime in seconds.
140    pub fn uptime(&self) -> f64 {
141        self.start_time.elapsed().as_secs_f64()
142    }
143
144    /// Get the daemon's uptime formatted as a human-readable string.
145    pub fn uptime_human(&self) -> String {
146        let secs = self.start_time.elapsed().as_secs();
147        let hours = secs / 3600;
148        let minutes = (secs % 3600) / 60;
149        let seconds = secs % 60;
150        format!("{}h {}m {}s", hours, minutes, seconds)
151    }
152
153    /// Get cache statistics.
154    pub fn cache_stats(&self) -> SalsaCacheStats {
155        self.cache.stats()
156    }
157
158    /// Get the project path.
159    pub fn project(&self) -> &PathBuf {
160        &self.project
161    }
162
163    /// Get the number of indexed files.
164    pub async fn indexed_files(&self) -> usize {
165        *self.indexed_files.read().await
166    }
167
168    /// Get a summary of all sessions.
169    pub fn all_sessions_summary(&self) -> AllSessionsSummary {
170        let mut summary = AllSessionsSummary {
171            active_sessions: self.sessions.len(),
172            ..AllSessionsSummary::default()
173        };
174
175        for entry in self.sessions.iter() {
176            let stats = entry.value();
177            summary.total_raw_tokens += stats.raw_tokens;
178            summary.total_tldr_tokens += stats.tldr_tokens;
179            summary.total_requests += stats.requests;
180        }
181
182        summary
183    }
184
185    /// Get all hook statistics.
186    pub fn hook_stats(&self) -> HashMap<String, HookStats> {
187        self.hooks
188            .iter()
189            .map(|e| (e.key().clone(), e.value().clone()))
190            .collect()
191    }
192
193    /// Signal the daemon to shut down gracefully.
194    pub fn shutdown(&self) {
195        self.stopping.store(true, Ordering::SeqCst);
196        let _ = self.shutdown_tx.send(true);
197    }
198
199    /// Run the daemon main loop.
200    ///
201    /// This function blocks until the daemon is shut down via:
202    /// - A `Shutdown` command from a client
203    /// - A SIGTERM/SIGINT signal
204    /// - An error in the listener
205    pub async fn run(self: Arc<Self>, listener: IpcListener) -> DaemonResult<()> {
206        // Set status to Ready
207        {
208            let mut status = self.status.write().await;
209            *status = DaemonStatus::Ready;
210        }
211
212        // Set up signal handlers for graceful shutdown
213        #[cfg(unix)]
214        {
215            let daemon = Arc::clone(&self);
216            tokio::spawn(async move {
217                let mut sigterm =
218                    tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
219                        .expect("Failed to register SIGTERM handler");
220                let mut sigint =
221                    tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
222                        .expect("Failed to register SIGINT handler");
223
224                tokio::select! {
225                    _ = sigterm.recv() => {
226                        daemon.shutdown();
227                    }
228                    _ = sigint.recv() => {
229                        daemon.shutdown();
230                    }
231                }
232            });
233        }
234
235        // Main event loop
236        let idle_timeout = std::time::Duration::from_secs(self.config.idle_timeout_secs);
237
238        loop {
239            // Check for shutdown signal
240            if self.stopping.load(Ordering::SeqCst) {
241                break;
242            }
243
244            // Safety net: self-terminate if project directory no longer exists
245            if !self.project.exists() {
246                eprintln!(
247                    "Project directory {} no longer exists, shutting down",
248                    self.project.display()
249                );
250                break;
251            }
252
253            // Self-terminate after idle timeout with no client activity
254            {
255                let last = self.last_activity.read().await;
256                if last.elapsed() > idle_timeout {
257                    eprintln!(
258                        "No client activity for {}s, shutting down",
259                        self.config.idle_timeout_secs
260                    );
261                    break;
262                }
263            }
264
265            // Accept connection with timeout
266            let accept_future = listener.accept();
267            let timeout = tokio::time::Duration::from_millis(100);
268
269            match tokio::time::timeout(timeout, accept_future).await {
270                Ok(Ok(mut stream)) => {
271                    // Update activity timestamp
272                    *self.last_activity.write().await = Instant::now();
273
274                    // Handle the connection
275                    let daemon = Arc::clone(&self);
276                    tokio::spawn(async move {
277                        if let Err(e) = daemon.handle_connection(&mut stream).await {
278                            eprintln!("Connection error: {}", e);
279                        }
280                    });
281                }
282                Ok(Err(e)) => {
283                    // Accept error - log and continue
284                    eprintln!("Accept error: {}", e);
285                }
286                Err(_) => {
287                    // Timeout - check shutdown and continue
288                    continue;
289                }
290            }
291        }
292
293        // Set status to ShuttingDown
294        {
295            let mut status = self.status.write().await;
296            *status = DaemonStatus::ShuttingDown;
297        }
298
299        // Persist stats before exit
300        self.persist_stats().await?;
301
302        // Set status to Stopped
303        {
304            let mut status = self.status.write().await;
305            *status = DaemonStatus::Stopped;
306        }
307
308        Ok(())
309    }
310
311    /// Handle a single client connection.
312    async fn handle_connection(self: &Arc<Self>, stream: &mut IpcStream) -> DaemonResult<()> {
313        // Read command
314        let cmd = read_command(stream).await?;
315
316        // Handle command
317        let response = self.handle_command(cmd).await;
318
319        // Send response
320        send_response(stream, &response).await?;
321
322        Ok(())
323    }
324
325    /// Handle a daemon command and return the response.
326    pub async fn handle_command(&self, cmd: DaemonCommand) -> DaemonResponse {
327        match cmd {
328            DaemonCommand::Ping => DaemonResponse::Status {
329                status: "ok".to_string(),
330                message: Some("pong".to_string()),
331            },
332
333            DaemonCommand::Status { session } => self.handle_status(session).await,
334
335            DaemonCommand::Shutdown => {
336                self.shutdown();
337                DaemonResponse::Status {
338                    status: "shutting_down".to_string(),
339                    message: Some("Daemon is shutting down".to_string()),
340                }
341            }
342
343            DaemonCommand::Notify { file } => self.handle_notify(file).await,
344
345            DaemonCommand::Track {
346                hook,
347                success,
348                metrics,
349            } => self.handle_track(hook, success, metrics).await,
350
351            DaemonCommand::Warm { language } => {
352                let lang = language
353                    .as_deref()
354                    .and_then(|l| l.parse::<Language>().ok())
355                    .unwrap_or(Language::Python);
356
357                let mut warmed = Vec::new();
358                let mut errors = Vec::new();
359
360                // 1. Warm call graph
361                let calls_key =
362                    QueryKey::new("calls", hash_str_args(&[&self.project.to_string_lossy()]));
363                if self.cache.get::<serde_json::Value>(&calls_key).is_some() {
364                    warmed.push("call_graph (cached)");
365                } else {
366                    match build_project_call_graph(&self.project, lang, None, true) {
367                        Ok(result) => {
368                            let val = serde_json::to_value(&result).unwrap_or_default();
369                            self.cache.insert(calls_key, &val, vec![]);
370                            warmed.push("call_graph");
371                        }
372                        Err(e) => errors.push(format!("call_graph: {}", e)),
373                    }
374                }
375
376                // 2. Warm code structure
377                let struct_key = QueryKey::new(
378                    "structure",
379                    hash_str_args(&[&self.project.to_string_lossy(), ""]),
380                );
381                if self.cache.get::<serde_json::Value>(&struct_key).is_some() {
382                    warmed.push("structure (cached)");
383                } else {
384                    match get_code_structure(&self.project, lang, 0, None) {
385                        Ok(result) => {
386                            let val = serde_json::to_value(&result).unwrap_or_default();
387                            self.cache.insert(struct_key, &val, vec![]);
388                            warmed.push("structure");
389                        }
390                        Err(e) => errors.push(format!("structure: {}", e)),
391                    }
392                }
393
394                // 3. Warm file tree
395                let tree_key =
396                    QueryKey::new("tree", hash_str_args(&[&self.project.to_string_lossy()]));
397                if self.cache.get::<serde_json::Value>(&tree_key).is_some() {
398                    warmed.push("file_tree (cached)");
399                } else {
400                    match get_file_tree(&self.project, None, true, None) {
401                        Ok(result) => {
402                            let file_count = count_tree_files(&result);
403                            let val = serde_json::to_value(&result).unwrap_or_default();
404                            self.cache.insert(tree_key, &val, vec![]);
405                            *self.indexed_files.write().await = file_count;
406                            warmed.push("file_tree");
407                        }
408                        Err(e) => errors.push(format!("file_tree: {}", e)),
409                    }
410                }
411
412                // 4. Warm semantic index
413                #[cfg(feature = "semantic")]
414                {
415                    let mut index_guard = self.semantic_index.write().await;
416                    if index_guard.is_some() {
417                        warmed.push("semantic_index (cached)");
418                    } else {
419                        let build_opts = BuildOptions {
420                            show_progress: false,
421                            use_cache: true,
422                            ..Default::default()
423                        };
424                        match SemanticIndex::build(
425                            &self.project,
426                            build_opts,
427                            Some(CacheConfig::default()),
428                        ) {
429                            Ok(idx) => {
430                                *index_guard = Some(idx);
431                                warmed.push("semantic_index");
432                            }
433                            Err(e) => errors.push(format!("semantic_index: {}", e)),
434                        }
435                    }
436                }
437
438                let message = if errors.is_empty() {
439                    format!("Warmed: {}", warmed.join(", "))
440                } else {
441                    format!(
442                        "Warmed: {}. Errors: {}",
443                        warmed.join(", "),
444                        errors.join("; ")
445                    )
446                };
447
448                DaemonResponse::Status {
449                    status: "ok".to_string(),
450                    message: Some(message),
451                }
452            }
453
454            #[cfg(feature = "semantic")]
455            DaemonCommand::Semantic { query, top_k } => {
456                // Semantic search with persistent index
457                let mut index_guard = self.semantic_index.write().await;
458
459                // Build index lazily on first query
460                if index_guard.is_none() {
461                    let build_opts = BuildOptions {
462                        show_progress: false,
463                        use_cache: true,
464                        ..Default::default()
465                    };
466                    let cache_config = Some(CacheConfig::default());
467
468                    match SemanticIndex::build(&self.project, build_opts, cache_config) {
469                        Ok(idx) => {
470                            *index_guard = Some(idx);
471                        }
472                        Err(e) => {
473                            return DaemonResponse::Error {
474                                status: "error".to_string(),
475                                error: format!("Failed to build semantic index: {}", e),
476                            };
477                        }
478                    }
479                }
480
481                // Search the index
482                let index = index_guard.as_mut().unwrap();
483                let search_opts = IndexSearchOptions {
484                    top_k,
485                    threshold: 0.5,
486                    include_snippet: true,
487                    snippet_lines: 5,
488                };
489
490                match index.search(&query, &search_opts) {
491                    Ok(report) => match serde_json::to_value(&report) {
492                        Ok(value) => DaemonResponse::Result(value),
493                        Err(e) => DaemonResponse::Error {
494                            status: "error".to_string(),
495                            error: format!("Serialization error: {}", e),
496                        },
497                    },
498                    Err(e) => DaemonResponse::Error {
499                        status: "error".to_string(),
500                        error: format!("Semantic search failed: {}", e),
501                    },
502                }
503            }
504
505            #[cfg(not(feature = "semantic"))]
506            DaemonCommand::Semantic { .. } => DaemonResponse::Error {
507                status: "error".to_string(),
508                error: "Semantic search requires the 'semantic' feature".to_string(),
509            },
510
511            // Pass-through analysis commands with Salsa cache integration
512            DaemonCommand::Search {
513                pattern,
514                max_results,
515            } => {
516                let max = max_results.unwrap_or(100);
517                let key = QueryKey::new("search", hash_str_args(&[&pattern, &max.to_string()]));
518                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
519                    return DaemonResponse::Result(cached);
520                }
521                match tldr_search(&pattern, &self.project, None, 2, max, 1000, None) {
522                    Ok(result) => {
523                        let val = serde_json::to_value(&result).unwrap_or_default();
524                        self.cache.insert(key, &val, vec![]);
525                        DaemonResponse::Result(val)
526                    }
527                    Err(e) => DaemonResponse::Error {
528                        status: "error".to_string(),
529                        error: e.to_string(),
530                    },
531                }
532            }
533
534            DaemonCommand::Extract { file, session: _ } => {
535                let file_str = file.to_string_lossy().to_string();
536                let key = QueryKey::new("extract", hash_str_args(&[&file_str]));
537                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
538                    return DaemonResponse::Result(cached);
539                }
540                let file_hash = super::salsa::hash_path(&file);
541                match extract_file(&file, Some(&self.project)) {
542                    Ok(result) => {
543                        let val = serde_json::to_value(&result).unwrap_or_default();
544                        self.cache.insert(key, &val, vec![file_hash]);
545                        DaemonResponse::Result(val)
546                    }
547                    Err(e) => DaemonResponse::Error {
548                        status: "error".to_string(),
549                        error: e.to_string(),
550                    },
551                }
552            }
553
554            DaemonCommand::Tree { path } => {
555                let root = path.unwrap_or_else(|| self.project.clone());
556                let root_str = root.to_string_lossy().to_string();
557                let key = QueryKey::new("tree", hash_str_args(&[&root_str]));
558                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
559                    return DaemonResponse::Result(cached);
560                }
561                match get_file_tree(&root, None, true, None) {
562                    Ok(result) => {
563                        let val = serde_json::to_value(&result).unwrap_or_default();
564                        self.cache.insert(key, &val, vec![]);
565                        DaemonResponse::Result(val)
566                    }
567                    Err(e) => DaemonResponse::Error {
568                        status: "error".to_string(),
569                        error: e.to_string(),
570                    },
571                }
572            }
573
574            DaemonCommand::Structure { path, lang } => {
575                let path_str = path.to_string_lossy().to_string();
576                let lang_str = lang.as_deref().unwrap_or("");
577                let key = QueryKey::new("structure", hash_str_args(&[&path_str, lang_str]));
578                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
579                    return DaemonResponse::Result(cached);
580                }
581                let language = match detect_or_parse_language(lang.as_deref(), &path) {
582                    Ok(l) => l,
583                    Err(e) => {
584                        return DaemonResponse::Error {
585                            status: "error".to_string(),
586                            error: e.to_string(),
587                        }
588                    }
589                };
590                match get_code_structure(&path, language, 0, None) {
591                    Ok(result) => {
592                        let val = serde_json::to_value(&result).unwrap_or_default();
593                        self.cache.insert(key, &val, vec![]);
594                        DaemonResponse::Result(val)
595                    }
596                    Err(e) => DaemonResponse::Error {
597                        status: "error".to_string(),
598                        error: e.to_string(),
599                    },
600                }
601            }
602
603            DaemonCommand::Context { entry, depth } => {
604                let d = depth.unwrap_or(2);
605                let key = QueryKey::new("context", hash_str_args(&[&entry, &d.to_string()]));
606                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
607                    return DaemonResponse::Result(cached);
608                }
609                match get_relevant_context(&self.project, &entry, d, Language::Python, true, None) {
610                    Ok(result) => {
611                        let val = serde_json::to_value(&result).unwrap_or_default();
612                        self.cache.insert(key, &val, vec![]);
613                        DaemonResponse::Result(val)
614                    }
615                    Err(e) => DaemonResponse::Error {
616                        status: "error".to_string(),
617                        error: e.to_string(),
618                    },
619                }
620            }
621
622            DaemonCommand::Cfg { file, function } => {
623                let file_str = file.to_string_lossy().to_string();
624                let key = QueryKey::new("cfg", hash_str_args(&[&file_str, &function]));
625                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
626                    return DaemonResponse::Result(cached);
627                }
628                let language = match detect_or_parse_language(None, &file) {
629                    Ok(l) => l,
630                    Err(e) => {
631                        return DaemonResponse::Error {
632                            status: "error".to_string(),
633                            error: e.to_string(),
634                        }
635                    }
636                };
637                let file_hash = super::salsa::hash_path(&file);
638                match get_cfg_context(&file_str, &function, language) {
639                    Ok(result) => {
640                        let val = serde_json::to_value(&result).unwrap_or_default();
641                        self.cache.insert(key, &val, vec![file_hash]);
642                        DaemonResponse::Result(val)
643                    }
644                    Err(e) => DaemonResponse::Error {
645                        status: "error".to_string(),
646                        error: e.to_string(),
647                    },
648                }
649            }
650
651            DaemonCommand::Dfg { file, function } => {
652                let file_str = file.to_string_lossy().to_string();
653                let key = QueryKey::new("dfg", hash_str_args(&[&file_str, &function]));
654                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
655                    return DaemonResponse::Result(cached);
656                }
657                let language = match detect_or_parse_language(None, &file) {
658                    Ok(l) => l,
659                    Err(e) => {
660                        return DaemonResponse::Error {
661                            status: "error".to_string(),
662                            error: e.to_string(),
663                        }
664                    }
665                };
666                let file_hash = super::salsa::hash_path(&file);
667                match get_dfg_context(&file_str, &function, language) {
668                    Ok(result) => {
669                        let val = serde_json::to_value(&result).unwrap_or_default();
670                        self.cache.insert(key, &val, vec![file_hash]);
671                        DaemonResponse::Result(val)
672                    }
673                    Err(e) => DaemonResponse::Error {
674                        status: "error".to_string(),
675                        error: e.to_string(),
676                    },
677                }
678            }
679
680            DaemonCommand::Slice {
681                file,
682                function,
683                line,
684            } => {
685                let file_str = file.to_string_lossy().to_string();
686                let key = QueryKey::new(
687                    "slice",
688                    hash_str_args(&[&file_str, &function, &line.to_string()]),
689                );
690                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
691                    return DaemonResponse::Result(cached);
692                }
693                let language = match detect_or_parse_language(None, &file) {
694                    Ok(l) => l,
695                    Err(e) => {
696                        return DaemonResponse::Error {
697                            status: "error".to_string(),
698                            error: e.to_string(),
699                        }
700                    }
701                };
702                let file_hash = super::salsa::hash_path(&file);
703                match get_slice(
704                    &file_str,
705                    &function,
706                    line as u32,
707                    SliceDirection::Backward,
708                    None,
709                    language,
710                ) {
711                    Ok(result) => {
712                        let val = serde_json::to_value(&result).unwrap_or_default();
713                        self.cache.insert(key, &val, vec![file_hash]);
714                        DaemonResponse::Result(val)
715                    }
716                    Err(e) => DaemonResponse::Error {
717                        status: "error".to_string(),
718                        error: e.to_string(),
719                    },
720                }
721            }
722
723            DaemonCommand::Calls { path } => {
724                let root = path.unwrap_or_else(|| self.project.clone());
725                let root_str = root.to_string_lossy().to_string();
726                let key = QueryKey::new("calls", hash_str_args(&[&root_str]));
727                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
728                    return DaemonResponse::Result(cached);
729                }
730                match build_project_call_graph(&root, Language::Python, None, true) {
731                    Ok(result) => {
732                        let val = serde_json::to_value(&result).unwrap_or_default();
733                        self.cache.insert(key, &val, vec![]);
734                        DaemonResponse::Result(val)
735                    }
736                    Err(e) => DaemonResponse::Error {
737                        status: "error".to_string(),
738                        error: e.to_string(),
739                    },
740                }
741            }
742
743            DaemonCommand::Impact { func, depth } => {
744                let d = depth.unwrap_or(3);
745                let key = QueryKey::new("impact", hash_str_args(&[&func, &d.to_string()]));
746                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
747                    return DaemonResponse::Result(cached);
748                }
749                let graph =
750                    match build_project_call_graph(&self.project, Language::Python, None, true) {
751                        Ok(g) => g,
752                        Err(e) => {
753                            return DaemonResponse::Error {
754                                status: "error".to_string(),
755                                error: e.to_string(),
756                            }
757                        }
758                    };
759                match impact_analysis(&graph, &func, d, None) {
760                    Ok(result) => {
761                        let val = serde_json::to_value(&result).unwrap_or_default();
762                        self.cache.insert(key, &val, vec![]);
763                        DaemonResponse::Result(val)
764                    }
765                    Err(e) => DaemonResponse::Error {
766                        status: "error".to_string(),
767                        error: e.to_string(),
768                    },
769                }
770            }
771
772            DaemonCommand::Dead { path, entry } => {
773                let root = path.unwrap_or_else(|| self.project.clone());
774                let root_str = root.to_string_lossy().to_string();
775                let entry_str = entry.as_ref().map(|v| v.join(",")).unwrap_or_default();
776                let key = QueryKey::new("dead", hash_str_args(&[&root_str, &entry_str]));
777                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
778                    return DaemonResponse::Result(cached);
779                }
780                let graph = match build_project_call_graph(&root, Language::Python, None, true) {
781                    Ok(g) => g,
782                    Err(e) => {
783                        return DaemonResponse::Error {
784                            status: "error".to_string(),
785                            error: e.to_string(),
786                        }
787                    }
788                };
789                // Collect all functions from the project by extracting each file
790                let extensions: HashSet<String> = Language::Python
791                    .extensions()
792                    .iter()
793                    .map(|s| s.to_string())
794                    .collect();
795                let file_tree = match get_file_tree(&root, Some(&extensions), true, None) {
796                    Ok(t) => t,
797                    Err(e) => {
798                        return DaemonResponse::Error {
799                            status: "error".to_string(),
800                            error: e.to_string(),
801                        }
802                    }
803                };
804                let files = tldr_core::fs::tree::collect_files(&file_tree, &root);
805                let mut module_infos = Vec::new();
806                for file_path in files {
807                    if let Ok(info) = extract_file(&file_path, Some(&root)) {
808                        module_infos.push((file_path, info));
809                    }
810                }
811                let all_functions = collect_all_functions(&module_infos);
812                let entry_strings: Option<Vec<String>> = entry;
813                let entry_refs: Option<&[String]> = entry_strings.as_deref();
814                match dead_code_analysis(&graph, &all_functions, entry_refs) {
815                    Ok(result) => {
816                        let val = serde_json::to_value(&result).unwrap_or_default();
817                        self.cache.insert(key, &val, vec![]);
818                        DaemonResponse::Result(val)
819                    }
820                    Err(e) => DaemonResponse::Error {
821                        status: "error".to_string(),
822                        error: e.to_string(),
823                    },
824                }
825            }
826
827            DaemonCommand::Arch { path } => {
828                let root = path.unwrap_or_else(|| self.project.clone());
829                let root_str = root.to_string_lossy().to_string();
830                let key = QueryKey::new("arch", hash_str_args(&[&root_str]));
831                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
832                    return DaemonResponse::Result(cached);
833                }
834                let graph = match build_project_call_graph(&root, Language::Python, None, true) {
835                    Ok(g) => g,
836                    Err(e) => {
837                        return DaemonResponse::Error {
838                            status: "error".to_string(),
839                            error: e.to_string(),
840                        }
841                    }
842                };
843                match architecture_analysis(&graph) {
844                    Ok(result) => {
845                        let val = serde_json::to_value(&result).unwrap_or_default();
846                        self.cache.insert(key, &val, vec![]);
847                        DaemonResponse::Result(val)
848                    }
849                    Err(e) => DaemonResponse::Error {
850                        status: "error".to_string(),
851                        error: e.to_string(),
852                    },
853                }
854            }
855
856            DaemonCommand::Imports { file } => {
857                let file_str = file.to_string_lossy().to_string();
858                let key = QueryKey::new("imports", hash_str_args(&[&file_str]));
859                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
860                    return DaemonResponse::Result(cached);
861                }
862                let language = match detect_or_parse_language(None, &file) {
863                    Ok(l) => l,
864                    Err(e) => {
865                        return DaemonResponse::Error {
866                            status: "error".to_string(),
867                            error: e.to_string(),
868                        }
869                    }
870                };
871                let file_hash = super::salsa::hash_path(&file);
872                match get_imports(&file, language) {
873                    Ok(result) => {
874                        let val = serde_json::to_value(&result).unwrap_or_default();
875                        self.cache.insert(key, &val, vec![file_hash]);
876                        DaemonResponse::Result(val)
877                    }
878                    Err(e) => DaemonResponse::Error {
879                        status: "error".to_string(),
880                        error: e.to_string(),
881                    },
882                }
883            }
884
885            DaemonCommand::Importers { module, path } => {
886                let root = path.unwrap_or_else(|| self.project.clone());
887                let root_str = root.to_string_lossy().to_string();
888                let key = QueryKey::new("importers", hash_str_args(&[&module, &root_str]));
889                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
890                    return DaemonResponse::Result(cached);
891                }
892                match find_importers(&root, &module, Language::Python) {
893                    Ok(result) => {
894                        let val = serde_json::to_value(&result).unwrap_or_default();
895                        self.cache.insert(key, &val, vec![]);
896                        DaemonResponse::Result(val)
897                    }
898                    Err(e) => DaemonResponse::Error {
899                        status: "error".to_string(),
900                        error: e.to_string(),
901                    },
902                }
903            }
904
905            DaemonCommand::Diagnostics { path, project: _ } => DaemonResponse::Error {
906                status: "error".to_string(),
907                error: format!(
908                    "Diagnostics requires external tool orchestration; \
909                         use CLI directly: tldr diagnostics {}",
910                    path.display()
911                ),
912            },
913
914            DaemonCommand::ChangeImpact {
915                files,
916                session: _,
917                git: _,
918            } => {
919                let files_str = files
920                    .as_ref()
921                    .map(|v| {
922                        v.iter()
923                            .map(|p| p.to_string_lossy().to_string())
924                            .collect::<Vec<_>>()
925                            .join(",")
926                    })
927                    .unwrap_or_default();
928                let key = QueryKey::new("change_impact", hash_str_args(&[&files_str]));
929                if let Some(cached) = self.cache.get::<serde_json::Value>(&key) {
930                    return DaemonResponse::Result(cached);
931                }
932                let changed: Option<Vec<PathBuf>> = files;
933                match change_impact(&self.project, changed.as_deref(), Language::Python) {
934                    Ok(result) => {
935                        let val = serde_json::to_value(&result).unwrap_or_default();
936                        self.cache.insert(key, &val, vec![]);
937                        DaemonResponse::Result(val)
938                    }
939                    Err(e) => DaemonResponse::Error {
940                        status: "error".to_string(),
941                        error: e.to_string(),
942                    },
943                }
944            }
945        }
946    }
947
948    /// Handle the Status command.
949    async fn handle_status(&self, session: Option<String>) -> DaemonResponse {
950        let status = self.status().await;
951        let uptime = self.uptime();
952        let files = self.indexed_files().await;
953        let salsa_stats = self.cache_stats();
954        let all_sessions = Some(self.all_sessions_summary());
955        let hook_stats = Some(self.hook_stats());
956
957        // Get session-specific stats if requested
958        let session_stats =
959            session.and_then(|id| self.sessions.get(&id).map(|entry| entry.value().clone()));
960
961        DaemonResponse::FullStatus {
962            status,
963            uptime,
964            files,
965            project: self.project.clone(),
966            salsa_stats,
967            dedup_stats: None,
968            session_stats,
969            all_sessions,
970            hook_stats,
971        }
972    }
973
974    /// Handle the Notify command (file change notification).
975    async fn handle_notify(&self, file: PathBuf) -> DaemonResponse {
976        // Add file to dirty set
977        let dirty_count = {
978            let mut dirty = self.dirty_files.write().await;
979            dirty.insert(file.clone());
980            dirty.len()
981        };
982
983        // Invalidate cache entries for this file
984        let file_hash = super::salsa::hash_path(&file);
985        self.cache.invalidate_by_input(file_hash);
986
987        // Invalidate semantic index so it rebuilds on next query
988        #[cfg(feature = "semantic")]
989        {
990            let mut idx = self.semantic_index.write().await;
991            *idx = None;
992        }
993
994        let threshold = self.config.auto_reindex_threshold;
995        let reindex_triggered = dirty_count >= threshold;
996
997        // Trigger reindex if threshold reached
998        if reindex_triggered {
999            // Clear dirty set
1000            let mut dirty = self.dirty_files.write().await;
1001            dirty.clear();
1002
1003            // In full implementation, would spawn background reindex task
1004            // For now, just clear the dirty set
1005        }
1006
1007        DaemonResponse::NotifyResponse {
1008            status: "ok".to_string(),
1009            dirty_count,
1010            threshold,
1011            reindex_triggered,
1012        }
1013    }
1014
1015    /// Handle the Track command (hook activity tracking).
1016    async fn handle_track(
1017        &self,
1018        hook: String,
1019        success: bool,
1020        metrics: HashMap<String, f64>,
1021    ) -> DaemonResponse {
1022        // Get or create hook stats
1023        let mut entry = self
1024            .hooks
1025            .entry(hook.clone())
1026            .or_insert_with(|| HookStats::new(hook.clone()));
1027
1028        // Record invocation
1029        let metrics_opt = if metrics.is_empty() {
1030            None
1031        } else {
1032            Some(metrics)
1033        };
1034        entry.record_invocation(success, metrics_opt);
1035
1036        let total_invocations = entry.invocations;
1037        let flushed = total_invocations.is_multiple_of(HOOK_FLUSH_THRESHOLD as u64);
1038
1039        // Flush stats periodically
1040        if flushed {
1041            // In full implementation, would persist stats to disk
1042            // For now, just mark as flushed
1043        }
1044
1045        DaemonResponse::TrackResponse {
1046            status: "ok".to_string(),
1047            hook,
1048            total_invocations,
1049            flushed,
1050        }
1051    }
1052
1053    /// Persist statistics to disk.
1054    async fn persist_stats(&self) -> DaemonResult<()> {
1055        // Create cache directory if it doesn't exist
1056        let cache_dir = self.project.join(".tldr/cache");
1057        if !cache_dir.exists() {
1058            std::fs::create_dir_all(&cache_dir)?;
1059        }
1060
1061        // Save Salsa cache stats
1062        let salsa_stats_path = cache_dir.join("salsa_stats.json");
1063        let stats = self.cache_stats();
1064        let json = serde_json::to_string_pretty(&stats)?;
1065        std::fs::write(salsa_stats_path, json)?;
1066
1067        // Save full query cache
1068        let cache_path = cache_dir.join("query_cache.bin");
1069        self.cache.save_to_file(&cache_path)?;
1070
1071        Ok(())
1072    }
1073}
1074
1075// =============================================================================
1076// Daemon Control Functions
1077// =============================================================================
1078
1079/// Start a daemon in the background for the given project.
1080///
1081/// Returns the PID of the daemon process.
1082pub async fn start_daemon_background(project: &std::path::Path) -> DaemonResult<u32> {
1083    use std::process::Command;
1084
1085    // Get the current executable path
1086    let exe_path = std::env::current_exe().map_err(DaemonError::Io)?;
1087
1088    // Spawn the daemon process
1089    #[cfg(unix)]
1090    {
1091        use std::os::unix::process::CommandExt;
1092
1093        let child = unsafe {
1094            Command::new(&exe_path)
1095                .args(["daemon", "start", "--project"])
1096                .arg(project.as_os_str())
1097                .arg("--foreground")
1098                .stdin(std::process::Stdio::null())
1099                .stdout(std::process::Stdio::null())
1100                .stderr(std::process::Stdio::null())
1101                .pre_exec(|| {
1102                    // Create new session (detach from terminal)
1103                    libc::setsid();
1104                    Ok(())
1105                })
1106                .spawn()
1107                .map_err(DaemonError::Io)?
1108        };
1109
1110        Ok(child.id())
1111    }
1112
1113    #[cfg(windows)]
1114    {
1115        use std::os::windows::process::CommandExt;
1116        const DETACHED_PROCESS: u32 = 0x00000008;
1117        const CREATE_NO_WINDOW: u32 = 0x08000000;
1118
1119        let child = Command::new(&exe_path)
1120            .args(["daemon", "start", "--project"])
1121            .arg(project.as_os_str())
1122            .arg("--foreground")
1123            .stdin(std::process::Stdio::null())
1124            .stdout(std::process::Stdio::null())
1125            .stderr(std::process::Stdio::null())
1126            .creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW)
1127            .spawn()
1128            .map_err(DaemonError::Io)?;
1129
1130        Ok(child.id())
1131    }
1132}
1133
1134/// Wait for a daemon to become ready by polling the socket.
1135///
1136/// Returns `Ok(())` if the daemon becomes available within the timeout.
1137pub async fn wait_for_daemon(project: &std::path::Path, timeout_secs: u64) -> DaemonResult<()> {
1138    let start = Instant::now();
1139    let timeout = std::time::Duration::from_secs(timeout_secs);
1140
1141    while start.elapsed() < timeout {
1142        // Try to connect
1143        if super::ipc::check_socket_alive(project).await {
1144            return Ok(());
1145        }
1146
1147        // Wait a bit before retrying
1148        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1149    }
1150
1151    Err(DaemonError::ConnectionTimeout { timeout_secs })
1152}
1153
1154// =============================================================================
1155// Tests
1156// =============================================================================
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161    use tempfile::TempDir;
1162
1163    #[test]
1164    fn test_daemon_new() {
1165        let temp = TempDir::new().unwrap();
1166        let config = DaemonConfig::default();
1167        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1168
1169        assert_eq!(daemon.project(), temp.path());
1170        assert!(daemon.uptime() < 1.0);
1171    }
1172
1173    #[tokio::test]
1174    async fn test_daemon_status_initial() {
1175        let temp = TempDir::new().unwrap();
1176        let config = DaemonConfig::default();
1177        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1178
1179        assert_eq!(daemon.status().await, DaemonStatus::Initializing);
1180    }
1181
1182    #[tokio::test]
1183    async fn test_daemon_uptime_human() {
1184        let temp = TempDir::new().unwrap();
1185        let config = DaemonConfig::default();
1186        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1187
1188        let uptime = daemon.uptime_human();
1189        assert!(uptime.contains("h"));
1190        assert!(uptime.contains("m"));
1191        assert!(uptime.contains("s"));
1192    }
1193
1194    #[tokio::test]
1195    async fn test_daemon_handle_ping() {
1196        let temp = TempDir::new().unwrap();
1197        let config = DaemonConfig::default();
1198        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1199
1200        let response = daemon.handle_command(DaemonCommand::Ping).await;
1201
1202        match response {
1203            DaemonResponse::Status { status, message } => {
1204                assert_eq!(status, "ok");
1205                assert_eq!(message, Some("pong".to_string()));
1206            }
1207            _ => panic!("Expected Status response"),
1208        }
1209    }
1210
1211    #[tokio::test]
1212    async fn test_daemon_handle_shutdown() {
1213        let temp = TempDir::new().unwrap();
1214        let config = DaemonConfig::default();
1215        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1216
1217        let response = daemon.handle_command(DaemonCommand::Shutdown).await;
1218
1219        match response {
1220            DaemonResponse::Status { status, .. } => {
1221                assert_eq!(status, "shutting_down");
1222            }
1223            _ => panic!("Expected Status response"),
1224        }
1225
1226        // Verify daemon is stopping
1227        assert!(daemon.stopping.load(Ordering::SeqCst));
1228    }
1229
1230    #[tokio::test]
1231    async fn test_daemon_handle_notify() {
1232        let temp = TempDir::new().unwrap();
1233        let config = DaemonConfig::default();
1234        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1235
1236        let file = temp.path().join("test.rs");
1237        let response = daemon.handle_command(DaemonCommand::Notify { file }).await;
1238
1239        match response {
1240            DaemonResponse::NotifyResponse {
1241                dirty_count,
1242                threshold,
1243                reindex_triggered,
1244                ..
1245            } => {
1246                assert_eq!(dirty_count, 1);
1247                assert_eq!(threshold, DEFAULT_REINDEX_THRESHOLD);
1248                assert!(!reindex_triggered);
1249            }
1250            _ => panic!("Expected NotifyResponse"),
1251        }
1252    }
1253
1254    #[tokio::test]
1255    async fn test_daemon_handle_notify_threshold() {
1256        let temp = TempDir::new().unwrap();
1257        let config = DaemonConfig {
1258            auto_reindex_threshold: 3, // Lower threshold for testing
1259            ..DaemonConfig::default()
1260        };
1261        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1262
1263        // Add files up to threshold
1264        for i in 0..3 {
1265            let file = temp.path().join(format!("test{}.rs", i));
1266            daemon.handle_command(DaemonCommand::Notify { file }).await;
1267        }
1268
1269        // The third notification should trigger reindex
1270        let file = temp.path().join("test3.rs");
1271        let response = daemon.handle_command(DaemonCommand::Notify { file }).await;
1272
1273        match response {
1274            DaemonResponse::NotifyResponse {
1275                reindex_triggered: _,
1276                ..
1277            } => {
1278                // After threshold is hit, dirty set is cleared
1279                // So reindex_triggered would have been true, but dirty_count is now 1
1280            }
1281            _ => panic!("Expected NotifyResponse"),
1282        }
1283    }
1284
1285    #[tokio::test]
1286    async fn test_daemon_handle_track() {
1287        let temp = TempDir::new().unwrap();
1288        let config = DaemonConfig::default();
1289        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1290
1291        let response = daemon
1292            .handle_command(DaemonCommand::Track {
1293                hook: "test-hook".to_string(),
1294                success: true,
1295                metrics: HashMap::new(),
1296            })
1297            .await;
1298
1299        match response {
1300            DaemonResponse::TrackResponse {
1301                hook,
1302                total_invocations,
1303                ..
1304            } => {
1305                assert_eq!(hook, "test-hook");
1306                assert_eq!(total_invocations, 1);
1307            }
1308            _ => panic!("Expected TrackResponse"),
1309        }
1310    }
1311
1312    #[tokio::test]
1313    async fn test_daemon_all_sessions_summary() {
1314        let temp = TempDir::new().unwrap();
1315        let config = DaemonConfig::default();
1316        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1317
1318        // Add a session
1319        daemon.sessions.insert(
1320            "test-session".to_string(),
1321            SessionStats {
1322                session_id: "test-session".to_string(),
1323                raw_tokens: 1000,
1324                tldr_tokens: 100,
1325                requests: 10,
1326                started_at: None,
1327            },
1328        );
1329
1330        let summary = daemon.all_sessions_summary();
1331        assert_eq!(summary.active_sessions, 1);
1332        assert_eq!(summary.total_raw_tokens, 1000);
1333        assert_eq!(summary.total_tldr_tokens, 100);
1334        assert_eq!(summary.total_requests, 10);
1335    }
1336
1337    // =========================================================================
1338    // Pass-through handler tests
1339    // =========================================================================
1340
1341    /// Helper to create a temp dir with a Python file for testing
1342    fn create_test_project() -> TempDir {
1343        let temp = TempDir::new().unwrap();
1344        let py_file = temp.path().join("main.py");
1345        std::fs::write(
1346            &py_file,
1347            "def hello():\n    \"\"\"Say hello.\"\"\"\n    return 'hello'\n\ndef main():\n    hello()\n",
1348        )
1349        .unwrap();
1350        temp
1351    }
1352
1353    #[test]
1354    fn test_hash_str_args_deterministic() {
1355        let h1 = hash_str_args(&["search", "pattern", "100"]);
1356        let h2 = hash_str_args(&["search", "pattern", "100"]);
1357        assert_eq!(h1, h2);
1358    }
1359
1360    #[test]
1361    fn test_hash_str_args_different_inputs() {
1362        let h1 = hash_str_args(&["search", "pattern_a"]);
1363        let h2 = hash_str_args(&["search", "pattern_b"]);
1364        assert_ne!(h1, h2);
1365    }
1366
1367    #[tokio::test]
1368    async fn test_daemon_search_returns_result() {
1369        let temp = create_test_project();
1370        let config = DaemonConfig::default();
1371        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1372
1373        let response = daemon
1374            .handle_command(DaemonCommand::Search {
1375                pattern: "def hello".to_string(),
1376                max_results: Some(10),
1377            })
1378            .await;
1379
1380        match response {
1381            DaemonResponse::Result(val) => {
1382                assert!(val.is_array(), "Search should return an array of matches");
1383                let arr = val.as_array().unwrap();
1384                assert!(
1385                    !arr.is_empty(),
1386                    "Should find at least one match for 'def hello'"
1387                );
1388            }
1389            DaemonResponse::Error { error, .. } => {
1390                panic!("Search returned error: {}", error);
1391            }
1392            other => panic!("Expected Result response, got {:?}", other),
1393        }
1394    }
1395
1396    #[tokio::test]
1397    async fn test_daemon_search_caches_result() {
1398        let temp = create_test_project();
1399        let config = DaemonConfig::default();
1400        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1401
1402        // First call populates cache
1403        let _r1 = daemon
1404            .handle_command(DaemonCommand::Search {
1405                pattern: "def hello".to_string(),
1406                max_results: Some(10),
1407            })
1408            .await;
1409
1410        // Second call should hit cache
1411        let _r2 = daemon
1412            .handle_command(DaemonCommand::Search {
1413                pattern: "def hello".to_string(),
1414                max_results: Some(10),
1415            })
1416            .await;
1417
1418        let stats = daemon.cache_stats();
1419        assert!(stats.hits >= 1, "Second call should hit cache");
1420    }
1421
1422    #[tokio::test]
1423    async fn test_daemon_extract_returns_result() {
1424        let temp = create_test_project();
1425        let config = DaemonConfig::default();
1426        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1427
1428        let response = daemon
1429            .handle_command(DaemonCommand::Extract {
1430                file: temp.path().join("main.py"),
1431                session: None,
1432            })
1433            .await;
1434
1435        match response {
1436            DaemonResponse::Result(val) => {
1437                assert!(
1438                    val.is_object(),
1439                    "Extract should return a module info object"
1440                );
1441                // Should contain functions field
1442                assert!(
1443                    val.get("functions").is_some(),
1444                    "Should have 'functions' field"
1445                );
1446            }
1447            DaemonResponse::Error { error, .. } => {
1448                panic!("Extract returned error: {}", error);
1449            }
1450            other => panic!("Expected Result response, got {:?}", other),
1451        }
1452    }
1453
1454    #[tokio::test]
1455    async fn test_daemon_extract_nonexistent_file() {
1456        let temp = TempDir::new().unwrap();
1457        let config = DaemonConfig::default();
1458        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1459
1460        let response = daemon
1461            .handle_command(DaemonCommand::Extract {
1462                file: temp.path().join("nonexistent.py"),
1463                session: None,
1464            })
1465            .await;
1466
1467        match response {
1468            DaemonResponse::Error { error, .. } => {
1469                assert!(
1470                    !error.is_empty(),
1471                    "Should return an error for nonexistent file"
1472                );
1473            }
1474            _ => panic!("Expected Error response for nonexistent file"),
1475        }
1476    }
1477
1478    #[tokio::test]
1479    async fn test_daemon_tree_returns_result() {
1480        let temp = create_test_project();
1481        let config = DaemonConfig::default();
1482        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1483
1484        let response = daemon
1485            .handle_command(DaemonCommand::Tree { path: None })
1486            .await;
1487
1488        match response {
1489            DaemonResponse::Result(val) => {
1490                assert!(val.is_object(), "Tree should return a FileTree object");
1491            }
1492            DaemonResponse::Error { error, .. } => {
1493                panic!("Tree returned error: {}", error);
1494            }
1495            other => panic!("Expected Result response, got {:?}", other),
1496        }
1497    }
1498
1499    #[tokio::test]
1500    async fn test_daemon_structure_returns_result() {
1501        let temp = create_test_project();
1502        let config = DaemonConfig::default();
1503        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1504
1505        let response = daemon
1506            .handle_command(DaemonCommand::Structure {
1507                path: temp.path().to_path_buf(),
1508                lang: Some("python".to_string()),
1509            })
1510            .await;
1511
1512        match response {
1513            DaemonResponse::Result(val) => {
1514                assert!(
1515                    val.is_object(),
1516                    "Structure should return a CodeStructure object"
1517                );
1518            }
1519            DaemonResponse::Error { error, .. } => {
1520                panic!("Structure returned error: {}", error);
1521            }
1522            other => panic!("Expected Result response, got {:?}", other),
1523        }
1524    }
1525
1526    #[tokio::test]
1527    async fn test_daemon_imports_returns_result() {
1528        let temp = create_test_project();
1529        let config = DaemonConfig::default();
1530        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1531
1532        let response = daemon
1533            .handle_command(DaemonCommand::Imports {
1534                file: temp.path().join("main.py"),
1535            })
1536            .await;
1537
1538        match response {
1539            DaemonResponse::Result(val) => {
1540                assert!(val.is_array(), "Imports should return an array");
1541            }
1542            DaemonResponse::Error { error, .. } => {
1543                panic!("Imports returned error: {}", error);
1544            }
1545            other => panic!("Expected Result response, got {:?}", other),
1546        }
1547    }
1548
1549    #[tokio::test]
1550    async fn test_daemon_cfg_returns_result() {
1551        let temp = create_test_project();
1552        let config = DaemonConfig::default();
1553        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1554
1555        let file = temp.path().join("main.py");
1556        let response = daemon
1557            .handle_command(DaemonCommand::Cfg {
1558                file,
1559                function: "hello".to_string(),
1560            })
1561            .await;
1562
1563        match response {
1564            DaemonResponse::Result(val) => {
1565                assert!(val.is_object(), "Cfg should return a CfgInfo object");
1566                assert!(
1567                    val.get("function").is_some(),
1568                    "Should have 'function' field"
1569                );
1570            }
1571            DaemonResponse::Error { error, .. } => {
1572                panic!("Cfg returned error: {}", error);
1573            }
1574            other => panic!("Expected Result response, got {:?}", other),
1575        }
1576    }
1577
1578    #[tokio::test]
1579    async fn test_daemon_dfg_returns_result() {
1580        let temp = create_test_project();
1581        let config = DaemonConfig::default();
1582        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1583
1584        let file = temp.path().join("main.py");
1585        let response = daemon
1586            .handle_command(DaemonCommand::Dfg {
1587                file,
1588                function: "hello".to_string(),
1589            })
1590            .await;
1591
1592        match response {
1593            DaemonResponse::Result(val) => {
1594                assert!(val.is_object(), "Dfg should return a DfgInfo object");
1595                assert!(
1596                    val.get("function").is_some(),
1597                    "Should have 'function' field"
1598                );
1599            }
1600            DaemonResponse::Error { error, .. } => {
1601                panic!("Dfg returned error: {}", error);
1602            }
1603            other => panic!("Expected Result response, got {:?}", other),
1604        }
1605    }
1606
1607    #[tokio::test]
1608    async fn test_daemon_calls_returns_result() {
1609        let temp = create_test_project();
1610        let config = DaemonConfig::default();
1611        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1612
1613        let response = daemon
1614            .handle_command(DaemonCommand::Calls { path: None })
1615            .await;
1616
1617        match response {
1618            DaemonResponse::Result(val) => {
1619                assert!(
1620                    val.is_object(),
1621                    "Calls should return a ProjectCallGraph object"
1622                );
1623            }
1624            DaemonResponse::Error { error, .. } => {
1625                panic!("Calls returned error: {}", error);
1626            }
1627            other => panic!("Expected Result response, got {:?}", other),
1628        }
1629    }
1630
1631    #[tokio::test]
1632    async fn test_daemon_arch_returns_result() {
1633        let temp = create_test_project();
1634        let config = DaemonConfig::default();
1635        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1636
1637        let response = daemon
1638            .handle_command(DaemonCommand::Arch { path: None })
1639            .await;
1640
1641        match response {
1642            DaemonResponse::Result(val) => {
1643                assert!(
1644                    val.is_object(),
1645                    "Arch should return an ArchitectureReport object"
1646                );
1647            }
1648            DaemonResponse::Error { error, .. } => {
1649                panic!("Arch returned error: {}", error);
1650            }
1651            other => panic!("Expected Result response, got {:?}", other),
1652        }
1653    }
1654
1655    #[tokio::test]
1656    async fn test_daemon_diagnostics_returns_error_with_guidance() {
1657        let temp = TempDir::new().unwrap();
1658        let config = DaemonConfig::default();
1659        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1660
1661        let path = temp.path().join("src");
1662        let response = daemon
1663            .handle_command(DaemonCommand::Diagnostics {
1664                path: path.clone(),
1665                project: None,
1666            })
1667            .await;
1668
1669        match response {
1670            DaemonResponse::Error { error, .. } => {
1671                assert!(
1672                    error.contains("Diagnostics requires external tool orchestration"),
1673                    "Error should explain that diagnostics needs CLI: {}",
1674                    error
1675                );
1676                assert!(
1677                    error.contains("tldr diagnostics"),
1678                    "Error should suggest CLI usage"
1679                );
1680            }
1681            other => panic!("Expected Error response, got {:?}", other),
1682        }
1683    }
1684
1685    #[tokio::test]
1686    async fn test_daemon_importers_returns_result() {
1687        let temp = create_test_project();
1688        let config = DaemonConfig::default();
1689        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1690
1691        let response = daemon
1692            .handle_command(DaemonCommand::Importers {
1693                module: "os".to_string(),
1694                path: None,
1695            })
1696            .await;
1697
1698        match response {
1699            DaemonResponse::Result(val) => {
1700                assert!(
1701                    val.is_object(),
1702                    "Importers should return an ImportersReport object"
1703                );
1704            }
1705            DaemonResponse::Error { error, .. } => {
1706                panic!("Importers returned error: {}", error);
1707            }
1708            other => panic!("Expected Result response, got {:?}", other),
1709        }
1710    }
1711
1712    #[tokio::test]
1713    async fn test_daemon_dead_returns_result() {
1714        let temp = create_test_project();
1715        let config = DaemonConfig::default();
1716        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1717
1718        let response = daemon
1719            .handle_command(DaemonCommand::Dead {
1720                path: None,
1721                entry: None,
1722            })
1723            .await;
1724
1725        match response {
1726            DaemonResponse::Result(val) => {
1727                assert!(
1728                    val.is_object(),
1729                    "Dead should return a DeadCodeReport object"
1730                );
1731            }
1732            DaemonResponse::Error { error, .. } => {
1733                panic!("Dead returned error: {}", error);
1734            }
1735            other => panic!("Expected Result response, got {:?}", other),
1736        }
1737    }
1738
1739    #[tokio::test]
1740    async fn test_daemon_change_impact_returns_result() {
1741        let temp = create_test_project();
1742        let config = DaemonConfig::default();
1743        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1744
1745        let response = daemon
1746            .handle_command(DaemonCommand::ChangeImpact {
1747                files: Some(vec![temp.path().join("main.py")]),
1748                session: None,
1749                git: None,
1750            })
1751            .await;
1752
1753        match response {
1754            DaemonResponse::Result(val) => {
1755                assert!(
1756                    val.is_object(),
1757                    "ChangeImpact should return a ChangeImpactReport object"
1758                );
1759            }
1760            DaemonResponse::Error { error, .. } => {
1761                panic!("ChangeImpact returned error: {}", error);
1762            }
1763            other => panic!("Expected Result response, got {:?}", other),
1764        }
1765    }
1766
1767    #[tokio::test]
1768    async fn test_daemon_extract_cache_invalidation() {
1769        let temp = create_test_project();
1770        let config = DaemonConfig::default();
1771        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1772
1773        let file = temp.path().join("main.py");
1774
1775        // First extract populates cache
1776        let r1 = daemon
1777            .handle_command(DaemonCommand::Extract {
1778                file: file.clone(),
1779                session: None,
1780            })
1781            .await;
1782        assert!(matches!(r1, DaemonResponse::Result(_)));
1783
1784        // Notify file change - should invalidate the cache entry
1785        daemon
1786            .handle_command(DaemonCommand::Notify { file: file.clone() })
1787            .await;
1788
1789        // After invalidation, next extract should be a cache miss
1790        let _r2 = daemon
1791            .handle_command(DaemonCommand::Extract {
1792                file,
1793                session: None,
1794            })
1795            .await;
1796
1797        let stats = daemon.cache_stats();
1798        // Should have: 1 miss (first), 1 invalidation, 1 miss (after invalidation)
1799        assert!(
1800            stats.invalidations >= 1,
1801            "File notify should have caused invalidation"
1802        );
1803    }
1804
1805    #[tokio::test]
1806    async fn test_daemon_slice_returns_result() {
1807        let temp = create_test_project();
1808        let config = DaemonConfig::default();
1809        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1810
1811        let file = temp.path().join("main.py");
1812        let response = daemon
1813            .handle_command(DaemonCommand::Slice {
1814                file,
1815                function: "hello".to_string(),
1816                line: 3,
1817            })
1818            .await;
1819
1820        match response {
1821            DaemonResponse::Result(val) => {
1822                assert!(
1823                    val.is_array(),
1824                    "Slice should return an array of line numbers"
1825                );
1826            }
1827            DaemonResponse::Error { error, .. } => {
1828                panic!("Slice returned error: {}", error);
1829            }
1830            other => panic!("Expected Result response, got {:?}", other),
1831        }
1832    }
1833
1834    #[tokio::test]
1835    async fn test_daemon_context_returns_result_or_error() {
1836        let temp = create_test_project();
1837        let config = DaemonConfig::default();
1838        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1839
1840        let response = daemon
1841            .handle_command(DaemonCommand::Context {
1842                entry: "main".to_string(),
1843                depth: Some(1),
1844            })
1845            .await;
1846
1847        // Context may return Result or Error depending on whether 'main' is found
1848        // in the call graph. Both are valid outcomes for this test.
1849        match response {
1850            DaemonResponse::Result(val) => {
1851                assert!(
1852                    val.is_object(),
1853                    "Context should return a RelevantContext object"
1854                );
1855            }
1856            DaemonResponse::Error { .. } => {
1857                // Function not found in call graph is acceptable
1858            }
1859            other => panic!("Expected Result or Error response, got {:?}", other),
1860        }
1861    }
1862
1863    #[tokio::test]
1864    async fn test_daemon_impact_returns_result_or_error() {
1865        let temp = create_test_project();
1866        let config = DaemonConfig::default();
1867        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1868
1869        let response = daemon
1870            .handle_command(DaemonCommand::Impact {
1871                func: "hello".to_string(),
1872                depth: Some(2),
1873            })
1874            .await;
1875
1876        // Impact may return Result or Error depending on call graph contents
1877        match response {
1878            DaemonResponse::Result(val) => {
1879                assert!(
1880                    val.is_object(),
1881                    "Impact should return an ImpactReport object"
1882                );
1883            }
1884            DaemonResponse::Error { .. } => {
1885                // Function not found in call graph is acceptable for small test projects
1886            }
1887            other => panic!("Expected Result or Error response, got {:?}", other),
1888        }
1889    }
1890
1891    #[cfg(feature = "semantic")]
1892    #[tokio::test]
1893    async fn test_semantic_search_builds_index() {
1894        // Create a temp dir with a simple Python file
1895        let temp = tempfile::tempdir().unwrap();
1896        let py_file = temp.path().join("hello.py");
1897        std::fs::write(
1898            &py_file,
1899            "def greet(name):\n    return f'Hello, {name}!'\n\ndef farewell(name):\n    return f'Goodbye, {name}!'\n",
1900        )
1901        .unwrap();
1902
1903        let config = DaemonConfig::default();
1904        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1905
1906        let response = daemon
1907            .handle_command(DaemonCommand::Semantic {
1908                query: "greeting function".to_string(),
1909                top_k: 5,
1910            })
1911            .await;
1912
1913        // Should return a Result with search results, not an error
1914        match &response {
1915            DaemonResponse::Result(value) => {
1916                assert!(value.get("query").is_some());
1917                assert!(value.get("results").is_some());
1918            }
1919            DaemonResponse::Error { error, .. } => {
1920                // May fail in CI without ONNX model - that's acceptable
1921                // but it should NOT say "not yet implemented"
1922                assert!(
1923                    !error.contains("not yet implemented"),
1924                    "Semantic search should be wired, got: {}",
1925                    error
1926                );
1927            }
1928            other => panic!("Unexpected response: {:?}", other),
1929        }
1930    }
1931
1932    #[cfg(feature = "semantic")]
1933    #[tokio::test]
1934    async fn test_semantic_index_invalidated_on_notify() {
1935        let temp = tempfile::tempdir().unwrap();
1936        let py_file = temp.path().join("example.py");
1937        std::fs::write(&py_file, "def compute(x):\n    return x * 2\n").unwrap();
1938
1939        let config = DaemonConfig::default();
1940        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1941
1942        // First semantic search - builds index
1943        let _ = daemon
1944            .handle_command(DaemonCommand::Semantic {
1945                query: "computation".to_string(),
1946                top_k: 5,
1947            })
1948            .await;
1949
1950        // Verify index is populated
1951        {
1952            let idx = daemon.semantic_index.read().await;
1953            // Index may be Some (if ONNX model available) or None (if build failed)
1954            // We just verify the field exists and is accessible
1955            let _ = idx.is_some();
1956        }
1957
1958        // Notify a file change - should invalidate the index
1959        let _ = daemon
1960            .handle_command(DaemonCommand::Notify {
1961                file: py_file.clone(),
1962            })
1963            .await;
1964
1965        // Verify index was cleared
1966        {
1967            let idx = daemon.semantic_index.read().await;
1968            assert!(
1969                idx.is_none(),
1970                "Semantic index should be invalidated after Notify"
1971            );
1972        }
1973    }
1974
1975    #[tokio::test]
1976    async fn test_daemon_warm_wires_caches() {
1977        let temp = tempfile::tempdir().unwrap();
1978        let py_file = temp.path().join("example.py");
1979        std::fs::write(
1980            &py_file,
1981            "def add(a, b):\n    return a + b\n\ndef multiply(x, y):\n    return x * y\n",
1982        )
1983        .unwrap();
1984
1985        let config = DaemonConfig::default();
1986        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1987
1988        let response = daemon
1989            .handle_command(DaemonCommand::Warm { language: None })
1990            .await;
1991
1992        match &response {
1993            DaemonResponse::Status { status, message } => {
1994                assert_eq!(status, "ok");
1995                let msg = message.as_deref().unwrap_or("");
1996                // Should mention what was warmed, not just "Warm completed"
1997                assert!(
1998                    msg.contains("Warmed"),
1999                    "Expected warm details, got: {}",
2000                    msg
2001                );
2002            }
2003            other => panic!("Expected Status response, got {:?}", other),
2004        }
2005    }
2006
2007    #[tokio::test]
2008    async fn test_daemon_warm_with_language() {
2009        let temp = tempfile::tempdir().unwrap();
2010        let rs_file = temp.path().join("lib.rs");
2011        std::fs::write(
2012            &rs_file,
2013            "pub fn hello() -> String {\n    \"hello\".to_string()\n}\n",
2014        )
2015        .unwrap();
2016
2017        let config = DaemonConfig::default();
2018        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
2019
2020        let response = daemon
2021            .handle_command(DaemonCommand::Warm {
2022                language: Some("rust".to_string()),
2023            })
2024            .await;
2025
2026        match &response {
2027            DaemonResponse::Status { status, .. } => {
2028                assert_eq!(status, "ok");
2029            }
2030            other => panic!("Expected Status response, got {:?}", other),
2031        }
2032    }
2033
2034    #[tokio::test]
2035    async fn test_daemon_last_activity_updated_on_command() {
2036        let temp = tempfile::tempdir().unwrap();
2037        let config = DaemonConfig::default();
2038        let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
2039
2040        // Record initial activity time
2041        let before = *daemon.last_activity.read().await;
2042
2043        // Small delay to ensure time difference
2044        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
2045
2046        // Any command should NOT update last_activity (only connections do),
2047        // but handle_command is what we can test. Verify the field exists and is accessible.
2048        let _ = daemon.handle_command(DaemonCommand::Ping).await;
2049
2050        // last_activity is set at connection accept, not command handling,
2051        // so it should still be the initial value
2052        let after = *daemon.last_activity.read().await;
2053        assert_eq!(before, after);
2054    }
2055
2056    #[tokio::test]
2057    async fn test_daemon_created_with_nonexistent_project() {
2058        // Daemon should be constructable with any path — the exists() check
2059        // happens in the run loop, not in new()
2060        let fake_path = PathBuf::from("/tmp/nonexistent-project-dir-12345");
2061        let config = DaemonConfig::default();
2062        let daemon = TLDRDaemon::new(fake_path.clone(), config);
2063
2064        assert_eq!(daemon.project(), &fake_path);
2065        // The project doesn't exist, but daemon construction succeeds.
2066        // The run() loop would detect this and self-terminate.
2067        assert!(!fake_path.exists());
2068    }
2069}