1use 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
45fn 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
58pub(crate) fn resolve_language(language: Option<Language>) -> Language {
70 language.unwrap_or(Language::Python)
71}
72
73fn 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
81pub struct TLDRDaemon {
94 project: PathBuf,
96 config: DaemonConfig,
98 start_time: Instant,
100 status: Arc<RwLock<DaemonStatus>>,
102 cache: QueryCache,
104 sessions: DashMap<String, SessionStats>,
106 hooks: DashMap<String, HookStats>,
108 dirty_files: Arc<RwLock<HashSet<PathBuf>>>,
110 shutdown_tx: watch::Sender<bool>,
112 stopping: AtomicBool,
114 last_activity: Arc<RwLock<Instant>>,
116 indexed_files: Arc<RwLock<usize>>,
118 #[cfg(feature = "semantic")]
120 semantic_index: Arc<RwLock<Option<SemanticIndex>>>,
121}
122
123impl TLDRDaemon {
124 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 pub async fn status(&self) -> DaemonStatus {
151 *self.status.read().await
152 }
153
154 pub fn uptime(&self) -> f64 {
156 self.start_time.elapsed().as_secs_f64()
157 }
158
159 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 pub fn cache_stats(&self) -> SalsaCacheStats {
170 self.cache.stats()
171 }
172
173 pub fn project(&self) -> &PathBuf {
175 &self.project
176 }
177
178 pub async fn indexed_files(&self) -> usize {
180 *self.indexed_files.read().await
181 }
182
183 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 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 pub fn shutdown(&self) {
210 self.stopping.store(true, Ordering::SeqCst);
211 let _ = self.shutdown_tx.send(true);
212 }
213
214 pub async fn run(self: Arc<Self>, listener: IpcListener) -> DaemonResult<()> {
221 {
223 let mut status = self.status.write().await;
224 *status = DaemonStatus::Ready;
225 }
226
227 #[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 let idle_timeout = std::time::Duration::from_secs(self.config.idle_timeout_secs);
252
253 loop {
254 if self.stopping.load(Ordering::SeqCst) {
256 break;
257 }
258
259 if !self.project.exists() {
261 eprintln!(
262 "Project directory {} no longer exists, shutting down",
263 self.project.display()
264 );
265 break;
266 }
267
268 {
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 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 *self.last_activity.write().await = Instant::now();
288
289 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 eprintln!("Accept error: {}", e);
300 }
301 Err(_) => {
302 continue;
304 }
305 }
306 }
307
308 {
310 let mut status = self.status.write().await;
311 *status = DaemonStatus::ShuttingDown;
312 }
313
314 self.persist_stats().await?;
316
317 {
319 let mut status = self.status.write().await;
320 *status = DaemonStatus::Stopped;
321 }
322
323 Ok(())
324 }
325
326 async fn handle_connection(self: &Arc<Self>, stream: &mut IpcStream) -> DaemonResult<()> {
328 let cmd = read_command(stream).await?;
330
331 let response = self.handle_command(cmd).await;
333
334 send_response(stream, &response).await?;
336
337 Ok(())
338 }
339
340 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 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 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 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 #[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 let mut index_guard = self.semantic_index.write().await;
478
479 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 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 DaemonCommand::Search {
533 pattern,
534 max_results,
535 } => {
536 let max = max_results.unwrap_or(100);
537 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 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 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 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 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 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 async fn handle_notify(&self, file: PathBuf) -> DaemonResponse {
1077 let dirty_count = {
1079 let mut dirty = self.dirty_files.write().await;
1080 dirty.insert(file.clone());
1081 dirty.len()
1082 };
1083
1084 let file_hash = super::salsa::hash_path(&file);
1086 self.cache.invalidate_by_input(file_hash);
1087
1088 #[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 if reindex_triggered {
1100 let mut dirty = self.dirty_files.write().await;
1102 dirty.clear();
1103
1104 }
1107
1108 DaemonResponse::NotifyResponse {
1109 status: "ok".to_string(),
1110 dirty_count,
1111 threshold,
1112 reindex_triggered,
1113 }
1114 }
1115
1116 async fn handle_track(
1118 &self,
1119 hook: String,
1120 success: bool,
1121 metrics: HashMap<String, f64>,
1122 ) -> DaemonResponse {
1123 let mut entry = self
1125 .hooks
1126 .entry(hook.clone())
1127 .or_insert_with(|| HookStats::new(hook.clone()));
1128
1129 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 if flushed {
1142 }
1145
1146 DaemonResponse::TrackResponse {
1147 status: "ok".to_string(),
1148 hook,
1149 total_invocations,
1150 flushed,
1151 }
1152 }
1153
1154 async fn persist_stats(&self) -> DaemonResult<()> {
1156 let cache_dir = self.project.join(".tldr/cache");
1158 if !cache_dir.exists() {
1159 std::fs::create_dir_all(&cache_dir)?;
1160 }
1161
1162 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 let cache_path = cache_dir.join("query_cache.bin");
1170 self.cache.save_to_file(&cache_path)?;
1171
1172 Ok(())
1173 }
1174}
1175
1176pub async fn start_daemon_background(project: &std::path::Path) -> DaemonResult<u32> {
1184 use std::process::Command;
1185
1186 let exe_path = std::env::current_exe().map_err(DaemonError::Io)?;
1188
1189 #[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 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
1235pub 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 if super::ipc::check_socket_alive(project).await {
1245 return Ok(());
1246 }
1247
1248 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1250 }
1251
1252 Err(DaemonError::ConnectionTimeout { timeout_secs })
1253}
1254
1255#[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 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, ..DaemonConfig::default()
1361 };
1362 let daemon = TLDRDaemon::new(temp.path().to_path_buf(), config);
1363
1364 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 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 }
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 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 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 let _r1 = daemon
1505 .handle_command(DaemonCommand::Search {
1506 pattern: "def hello".to_string(),
1507 max_results: Some(10),
1508 })
1509 .await;
1510
1511 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 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 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 daemon
1896 .handle_command(DaemonCommand::Notify { file: file.clone() })
1897 .await;
1898
1899 let _r2 = daemon
1901 .handle_command(DaemonCommand::Extract {
1902 file,
1903 session: None,
1904 })
1905 .await;
1906
1907 let stats = daemon.cache_stats();
1908 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 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 }
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 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 }
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 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 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 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 let _ = daemon
2056 .handle_command(DaemonCommand::Semantic {
2057 query: "computation".to_string(),
2058 top_k: 5,
2059 })
2060 .await;
2061
2062 {
2064 let idx = daemon.semantic_index.read().await;
2065 let _ = idx.is_some();
2068 }
2069
2070 let _ = daemon
2072 .handle_command(DaemonCommand::Notify {
2073 file: py_file.clone(),
2074 })
2075 .await;
2076
2077 {
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 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 let before = *daemon.last_activity.read().await;
2154
2155 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
2157
2158 let _ = daemon.handle_command(DaemonCommand::Ping).await;
2161
2162 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 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 assert!(!fake_path.exists());
2180 }
2181}