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