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