1use crate::tools::warm_guard::WarmGuard;
7use async_trait::async_trait;
8use limit_agent::AgentError;
9use limit_agent::Tool;
10use limit_tldr::{Config as TldrConfig, Language, TLDR};
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::Arc;
16use tokio::sync::{Notify, OnceCell};
17use tracing::{debug, info, warn};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AnalysisType {
23 Context,
25 Source,
27 Impact,
29 Cfg,
31 Dfg,
33 DeadCode,
35 Architecture,
37 Search,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct TldrParams {
44 pub analysis_type: AnalysisType,
46
47 pub function: Option<String>,
49
50 pub file: Option<String>,
52
53 #[serde(default = "default_depth")]
55 pub depth: usize,
56
57 #[serde(default = "default_entries")]
59 pub entries: Vec<String>,
60
61 pub query: Option<String>,
63
64 #[serde(default = "default_limit")]
66 pub limit: usize,
67
68 pub project_path: Option<String>,
70}
71
72fn default_depth() -> usize {
73 2
74}
75fn default_entries() -> Vec<String> {
76 vec!["main".to_string()]
77}
78fn default_limit() -> usize {
79 10
80}
81
82pub struct TldrTool {
84 cache: Arc<OnceCell<(PathBuf, Arc<TLDR>)>>,
86 default_project: PathBuf,
88 warm_notify: Arc<Notify>,
90 warm_started: Arc<AtomicBool>,
92}
93
94impl TldrTool {
95 pub fn new() -> Self {
97 let default_project = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
98 Self {
99 cache: Arc::new(OnceCell::new()),
100 default_project,
101 warm_notify: Arc::new(Notify::new()),
102 warm_started: Arc::new(AtomicBool::new(false)),
103 }
104 }
105
106 pub fn with_project<P: Into<PathBuf>>(project: P) -> Self {
108 Self {
109 cache: Arc::new(OnceCell::new()),
110 default_project: project.into(),
111 warm_notify: Arc::new(Notify::new()),
112 warm_started: Arc::new(AtomicBool::new(false)),
113 }
114 }
115
116 fn ensure_pre_warm_started(&self) {
118 if self
119 .warm_started
120 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
121 .is_ok()
122 {
123 let project = self.default_project.clone();
124 let cache = Arc::clone(&self.cache);
125 let notify = Arc::clone(&self.warm_notify);
126
127 tokio::spawn(async move {
128 Self::pre_warm(project, cache, notify).await;
129 });
130 }
131 }
132
133 async fn pre_warm(
135 project_path: PathBuf,
136 cache: Arc<OnceCell<(PathBuf, Arc<TLDR>)>>,
137 notify: Arc<Notify>,
138 ) {
139 let cache_dir = match Self::get_cache_dir(&project_path) {
140 Ok(dir) => dir,
141 Err(e) => {
142 warn!("pre_warm: failed to get cache dir: {}", e);
143 notify.notify_waiters();
144 return;
145 }
146 };
147
148 let guard = WarmGuard::new(&cache_dir);
149 if guard.is_fresh(&project_path) {
150 info!("pre_warm: skipping, warm is fresh");
151 notify.notify_waiters();
152 return;
153 }
154
155 info!("pre_warm: warming TLDR for {:?}", project_path);
156 let config = TldrConfig {
157 language: Language::Auto,
158 max_depth: 3,
159 cache_dir: Some(cache_dir),
160 };
161
162 match TLDR::new(&project_path, config).await {
163 Ok(mut tldr) => match tldr.warm().await {
164 Ok(()) => {
165 guard.save(&project_path);
166 info!("pre_warm: warm complete");
167 let _ = cache.set((project_path, Arc::new(tldr))).map_err(|_| {
168 debug!("pre_warm: OnceCell already set (race with get_tldr)");
169 });
170 notify.notify_waiters();
171 }
172 Err(e) => warn!("pre_warm: warm failed: {}", e),
173 },
174 Err(e) => warn!("pre_warm: TLDR::new failed: {}", e),
175 }
176 notify.notify_waiters();
177 }
178
179 async fn get_tldr(&self, project_path: &Path) -> Result<Arc<TLDR>, AgentError> {
184 let project_path = project_path.to_path_buf();
185 let project_path_for_check = project_path.clone();
186
187 self.ensure_pre_warm_started();
189
190 if let Some((cached_path, tldr)) = self.cache.get() {
192 if *cached_path == project_path_for_check {
193 debug!("TLDR cache hit for project: {:?}", project_path_for_check);
194 return Ok(Arc::clone(tldr));
195 }
196 return Err(AgentError::ToolError(format!(
197 "Project path mismatch: cached {:?} != requested {:?}",
198 cached_path, project_path_for_check
199 )));
200 }
201
202 info!("get_tldr: waiting for pre_warm...");
204 tokio::select! {
205 _ = self.warm_notify.notified() => {
206 if let Some((cached_path, tldr)) = self.cache.get() {
208 if *cached_path == project_path_for_check {
209 debug!("TLDR cache hit after pre_warm for: {:?}", project_path_for_check);
210 return Ok(Arc::clone(tldr));
211 }
212 }
213 warn!("get_tldr: pre_warm did not populate cache, falling back to lazy");
215 }
216 _ = tokio::time::sleep(std::time::Duration::from_secs(30)) => {
217 warn!("get_tldr: pre_warm timed out after 30s, falling back to lazy");
218 }
219 }
220
221 let cache = Arc::clone(&self.cache);
223 let result: Result<&(PathBuf, Arc<TLDR>), AgentError> = cache
224 .get_or_try_init(|| async {
225 info!(
226 "Lazy creating TLDR instance for project: {:?}",
227 project_path
228 );
229 let config = TldrConfig {
230 language: Language::Auto,
231 max_depth: 3,
232 cache_dir: Some(Self::get_cache_dir(&project_path)?),
233 };
234
235 let mut tldr = TLDR::new(&project_path, config)
236 .await
237 .map_err(|e| AgentError::ToolError(format!("Failed to create TLDR: {}", e)))?;
238
239 info!("Warming TLDR indexes...");
240 tldr.warm()
241 .await
242 .map_err(|e| AgentError::ToolError(format!("Failed to warm TLDR: {}", e)))?;
243
244 Ok((project_path, Arc::new(tldr)))
245 })
246 .await;
247
248 let (_cached_path, tldr) = result?;
249 debug!(
250 "TLDR cache hit (lazy) for project: {:?}",
251 project_path_for_check
252 );
253 Ok(Arc::clone(tldr))
254 }
255
256 fn get_cache_dir(project_path: &Path) -> Result<PathBuf, AgentError> {
258 let home = dirs::home_dir()
259 .ok_or_else(|| AgentError::ToolError("Cannot find home directory".into()))?;
260
261 let project_id = project_path
263 .canonicalize()
264 .map_err(|e| AgentError::ToolError(format!("Cannot canonicalize path: {}", e)))?
265 .to_string_lossy()
266 .to_string();
267
268 use std::collections::hash_map::DefaultHasher;
270 use std::hash::{Hash, Hasher};
271 let mut hasher = DefaultHasher::new();
272 project_id.hash(&mut hasher);
273 let hash = format!("{:x}", hasher.finish());
274
275 Ok(home
276 .join(".limit")
277 .join("projects")
278 .join(&hash)
279 .join("tldr"))
280 }
281
282 async fn build_source_result(
284 &self,
285 function: &str,
286 source_file: PathBuf,
287 start_line: usize,
288 end_line: usize,
289 project_path: &Path,
290 ) -> Result<Value, AgentError> {
291 let relative_file = source_file
292 .strip_prefix(project_path)
293 .unwrap_or(&source_file)
294 .to_path_buf();
295
296 let file_path = project_path.join(&source_file);
297 let source = tokio::fs::read_to_string(&file_path)
298 .await
299 .map_err(|e| AgentError::ToolError(format!("Failed to read file: {}", e)))?;
300
301 let lines: Vec<&str> = source.lines().collect();
302 let start = start_line.saturating_sub(1);
303 let end = end_line.min(lines.len());
304 let max_lines = 80;
305 let truncated = (end - start) > max_lines;
306 let actual_end = if truncated { start + max_lines } else { end };
307
308 let function_source = lines[start..actual_end].join("\n");
309
310 let mut result = json!({
311 "type": "source",
312 "function": function,
313 "file": relative_file.display().to_string(),
314 "line": start_line,
315 "end_line": actual_end,
316 "source": function_source
317 });
318 if truncated {
319 result["truncated"] = json!(true);
320 result["total_lines"] = json!(end - start);
321 }
322
323 Ok(result)
324 }
325
326 async fn analyze(&self, params: TldrParams) -> Result<Value, AgentError> {
328 let project_path = params
329 .project_path
330 .map(PathBuf::from)
331 .unwrap_or_else(|| self.default_project.clone());
332
333 let tldr = self.get_tldr(&project_path).await?;
334
335 let result = match params.analysis_type {
336 AnalysisType::Context => {
337 let function = params.function.ok_or_else(|| {
338 AgentError::ToolError("function parameter required for context analysis".into())
339 })?;
340
341 let context = tldr
342 .get_context(&function, params.depth)
343 .await
344 .map_err(|e| {
345 AgentError::ToolError(format!("Context analysis failed: {}", e))
346 })?;
347
348 Ok(json!({
349 "type": "context",
350 "function": function,
351 "depth": params.depth,
352 "context": context
353 }))
354 }
355
356 AnalysisType::Source => {
357 let function = params.function.ok_or_else(|| {
358 AgentError::ToolError("function parameter required for source analysis".into())
359 })?;
360
361 let (function, file_override) = if !function.starts_with("struct ") {
364 if let Some(pos) = function.find("::") {
365 let class_name = &function[..pos];
366 let method_name = &function[pos + 2..];
367 if !method_name.is_empty() {
368 let class_info = if let Some(ref file) = params.file {
369 let file_path = project_path.join(file);
370 tldr.find_class_in(class_name, &file_path).unwrap_or(None)
371 } else {
372 tldr.find_class(class_name).unwrap_or(None)
373 };
374 if let Some(info) = class_info {
375 let resolved_file = info
376 .file
377 .strip_prefix(&project_path)
378 .unwrap_or(&info.file)
379 .to_string_lossy()
380 .to_string();
381 (method_name.to_string(), Some(resolved_file))
382 } else {
383 (function, None)
384 }
385 } else {
386 (function, None)
387 }
388 } else {
389 (function, None)
390 }
391 } else {
392 (function, None)
393 };
394 let effective_file = file_override.or(params.file.clone());
396
397 let is_struct = function.starts_with("struct ");
398 let lookup_name = if is_struct {
399 function.strip_prefix("struct ").unwrap()
400 } else {
401 &function
402 };
403
404 let (source_file, start_line, end_line) = if is_struct {
405 let class_info = if let Some(ref file) = effective_file {
407 let file_path = project_path.join(file);
408 tldr.find_class_in(lookup_name, &file_path)
409 .map_err(|e| {
410 AgentError::ToolError(format!("Source analysis failed: {}", e))
411 })?
412 .ok_or_else(|| {
413 AgentError::ToolError(format!(
414 "Struct '{}' not found in '{}'",
415 lookup_name, file
416 ))
417 })?
418 } else {
419 tldr.find_class(lookup_name)
420 .map_err(|e| {
421 AgentError::ToolError(format!("Source analysis failed: {}", e))
422 })?
423 .ok_or_else(|| {
424 AgentError::ToolError(format!("Struct not found: {}", lookup_name))
425 })?
426 };
427 (class_info.file, class_info.line, class_info.end_line)
428 } else {
429 let func_info = if let Some(ref file) = effective_file {
431 let file_path = project_path.join(file);
432 if let Some(func) =
434 tldr.find_function_in(&function, &file_path).map_err(|e| {
435 AgentError::ToolError(format!("Source analysis failed: {}", e))
436 })?
437 {
438 func
439 } else if let Some(cls) =
440 tldr.find_class_in(&function, &file_path).map_err(|e| {
441 AgentError::ToolError(format!("Source analysis failed: {}", e))
442 })?
443 {
444 return self
446 .build_source_result(
447 &function,
448 cls.file,
449 cls.line,
450 cls.end_line,
451 &project_path,
452 )
453 .await;
454 } else {
455 return Err(AgentError::ToolError(format!(
456 "Function or struct '{}' not found in '{}'",
457 function, file
458 )));
459 }
460 } else {
461 let all_matches = tldr.find_all_functions(&function);
463 if all_matches.len() > 1 {
464 let match_list: Vec<String> = all_matches
465 .iter()
466 .take(5)
467 .map(|f| {
468 let relative =
469 f.file.strip_prefix(&project_path).unwrap_or(&f.file);
470 format!("{} ({}:{})", f.name, relative.display(), f.line)
471 })
472 .collect();
473 return Ok(json!({
474 "type": "disambiguation_needed",
475 "function": function,
476 "match_count": all_matches.len(),
477 "matches": match_list,
478 "hint": format!(
479 "Use file parameter to disambiguate, e.g.: {{\"analysis_type\": \"source\", \"function\": \"{}\", \"file\": \"path/to/file.rs\"}}",
480 function
481 )
482 }));
483 }
484 tldr.find_function(&function)
485 .await
486 .map_err(|e| {
487 AgentError::ToolError(format!("Source analysis failed: {}", e))
488 })?
489 .ok_or_else(|| {
490 AgentError::ToolError(format!("Function not found: {}", function))
491 })?
492 };
493 (func_info.file, func_info.line, func_info.end_line)
494 };
495
496 self.build_source_result(
497 &function,
498 source_file,
499 start_line,
500 end_line,
501 &project_path,
502 )
503 .await
504 }
505
506 AnalysisType::Impact => {
507 let function = params.function.ok_or_else(|| {
508 AgentError::ToolError("function parameter required for impact analysis".into())
509 })?;
510
511 let callers = tldr
512 .get_impact(&function)
513 .map_err(|e| AgentError::ToolError(format!("Impact analysis failed: {}", e)))?;
514
515 Ok(json!({
516 "type": "impact",
517 "function": function,
518 "callers": callers.iter().map(|c| json!({
519 "function": c.function,
520 "file": c.file.display().to_string(),
521 "line": c.line
522 })).collect::<Vec<_>>(),
523 "caller_count": callers.len()
524 }))
525 }
526
527 AnalysisType::Cfg => {
528 let file = params.file.ok_or_else(|| {
529 AgentError::ToolError("file parameter required for CFG analysis".into())
530 })?;
531 let function = params.function.ok_or_else(|| {
532 AgentError::ToolError("function parameter required for CFG analysis".into())
533 })?;
534
535 let file_path = project_path.join(&file);
536 let cfg = tldr
537 .get_cfg(&file_path, &function)
538 .map_err(|e| AgentError::ToolError(format!("CFG analysis failed: {}", e)))?;
539
540 Ok(json!({
541 "type": "cfg",
542 "function": function,
543 "file": file,
544 "complexity": cfg.complexity,
545 "blocks": cfg.blocks.len()
546 }))
547 }
548
549 AnalysisType::Dfg => {
550 let file = params.file.ok_or_else(|| {
551 AgentError::ToolError("file parameter required for DFG analysis".into())
552 })?;
553 let function = params.function.ok_or_else(|| {
554 AgentError::ToolError("function parameter required for DFG analysis".into())
555 })?;
556
557 let file_path = project_path.join(&file);
558 let dfg = tldr
559 .get_dfg(&file_path, &function)
560 .map_err(|e| AgentError::ToolError(format!("DFG analysis failed: {}", e)))?;
561
562 Ok(json!({
563 "type": "dfg",
564 "function": function,
565 "file": file,
566 "variables": dfg.variables,
567 "flows": dfg.flows.len()
568 }))
569 }
570
571 AnalysisType::DeadCode => {
572 let entries: Vec<&str> = params.entries.iter().map(|s| s.as_str()).collect();
573 let dead = tldr.find_dead_code(&entries).map_err(|e| {
574 AgentError::ToolError(format!("Dead code analysis failed: {}", e))
575 })?;
576
577 Ok(json!({
578 "type": "dead_code",
579 "entries": params.entries,
580 "dead_functions": dead.iter().map(|f| json!({
581 "name": f.name,
582 "file": f.file.display().to_string(),
583 "line": f.line
584 })).collect::<Vec<_>>(),
585 "dead_count": dead.len()
586 }))
587 }
588
589 AnalysisType::Architecture => {
590 let arch = tldr.detect_architecture().map_err(|e| {
591 AgentError::ToolError(format!("Architecture detection failed: {}", e))
592 })?;
593
594 let entry_sample: Vec<_> = arch.entry.iter().take(10).collect();
597 let middle_sample: Vec<_> = arch.middle.iter().take(10).collect();
598 let leaf_sample: Vec<_> = arch.leaf.iter().take(10).collect();
599
600 Ok(json!({
601 "type": "architecture",
602 "summary": {
603 "entry_points_count": arch.entry.len(),
604 "middle_layer_count": arch.middle.len(),
605 "leaf_functions_count": arch.leaf.len()
606 },
607 "sample_entry_points": entry_sample,
608 "sample_middle_layer": middle_sample,
609 "sample_leaf_functions": leaf_sample,
610 "note": "Showing top 10 of each category. Use Search analysis for specific functions."
611 }))
612 }
613
614 AnalysisType::Search => {
615 let query = params
616 .query
617 .unwrap_or_else(|| params.function.clone().unwrap_or_default());
618
619 let results = tldr
620 .semantic_search(&query, params.limit)
621 .await
622 .map_err(|e| AgentError::ToolError(format!("Search failed: {}", e)))?;
623
624 Ok(json!({
625 "type": "search",
626 "query": query,
627 "results": results.iter().map(|r| {
628 let relative = r
629 .file
630 .strip_prefix(&project_path)
631 .unwrap_or(&r.file);
632 json!({
633 "function": r.function,
634 "file": relative.display().to_string(),
635 "line": r.line,
636 "score": r.score,
637 "signature": r.signature
638 })
639 }).collect::<Vec<_>>()
640 }))
641 }
642 };
643
644 debug!("Analysis complete for: {:?}", params.analysis_type);
645 result
646 }
647}
648
649impl Default for TldrTool {
650 fn default() -> Self {
651 Self::new()
652 }
653}
654
655#[async_trait]
656impl Tool for TldrTool {
657 fn name(&self) -> &str {
658 "tldr_analyze"
659 }
660
661 async fn execute(&self, args: Value) -> Result<Value, AgentError> {
662 let params: TldrParams = serde_json::from_value(args)
664 .map_err(|e| AgentError::ToolError(format!("Invalid parameters: {}", e)))?;
665
666 info!("tldr_analyze invoked: type={:?}", params.analysis_type);
667 if let Some(ref f) = ¶ms.function {
668 debug!(" function: {}", f);
669 }
670 if let Some(ref q) = ¶ms.query {
671 debug!(" query: {}", q);
672 }
673
674 let result = match self.analyze(params).await {
675 Ok(r) => r,
676 Err(e) => {
677 tracing::warn!("tldr_analyze failed: {}", e);
678 return Err(e);
679 }
680 };
681 let result_str =
682 serde_json::to_string(&result).unwrap_or_else(|_| "serialize error".to_string());
683 info!(
684 "tldr_analyze result: {} chars, {} bytes",
685 result_str.chars().count(),
686 result_str.len()
687 );
688 Ok(result)
689 }
690}
691
692pub fn tldr_tool_definition() -> Value {
694 json!({
695 "name": "tldr_analyze",
696 "description": "Token-efficient code analysis. ALWAYS USE THIS when the user asks: 'what does X do', 'how does X work', 'explain X', 'tell me about X', 'what is X'. Saves 95% tokens vs reading raw code. Do NOT combine with file_read or bash — this tool provides all needed context. STRATEGY: (1) search to find functions, (2) source for 1-3 key functions only, (3) write answer. Do NOT read every function. Analysis types: search=find functions, context=dependencies, source=function code, impact=callers, architecture=layers.",
697 "parameters": {
698 "type": "object",
699 "properties": {
700 "analysis_type": {
701 "type": "string",
702 "enum": ["search", "context", "source", "impact", "cfg", "dfg", "dead_code", "architecture"],
703 "description": "Type: search=find by keyword, context=dependencies+callers, source=function code (use instead of file_read), impact=who calls this, cfg=control flow, dfg=data flow, dead_code=unreachable, architecture=module layers"
704 },
705 "function": {
706 "type": "string",
707 "description": "Function or struct name (required for context, source, impact, cfg, dfg). For structs, prefix with 'struct ' (e.g., 'struct AppConfig')"
708 },
709 "file": {
710 "type": "string",
711 "description": "File path relative to project root. Required for cfg, dfg. Optional for source (use to disambiguate when function name exists in multiple files)"
712 },
713 "depth": {
714 "type": "integer",
715 "description": "Depth for context traversal (default: 2)",
716 "default": 2
717 },
718 "entries": {
719 "type": "array",
720 "items": {"type": "string"},
721 "description": "Entry points for dead code detection (default: [\"main\"])",
722 "default": ["main"]
723 },
724 "query": {
725 "type": "string",
726 "description": "Search query for finding functions (supports patterns like 'daemon', 'auth', 'handle_*')"
727 },
728 "limit": {
729 "type": "integer",
730 "description": "Maximum results for search (default: 10)",
731 "default": 10
732 },
733 "project_path": {
734 "type": "string",
735 "description": "Project root directory (defaults to current directory). Do NOT use file paths here — use 'file' parameter for file paths."
736 }
737 },
738 "required": ["analysis_type"]
739 }
740 })
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746
747 #[test]
748 fn test_tool_definition() {
749 let def = tldr_tool_definition();
750 assert_eq!(def["name"], "tldr_analyze");
751 assert!(def["parameters"]["properties"]["analysis_type"]["enum"].is_array());
752 }
753
754 #[test]
755 fn test_params_deserialization() {
756 let json = json!({
757 "analysis_type": "context",
758 "function": "main",
759 "depth": 3
760 });
761
762 let params: TldrParams = serde_json::from_value(json).unwrap();
763 assert!(matches!(params.analysis_type, AnalysisType::Context));
764 assert_eq!(params.function, Some("main".to_string()));
765 assert_eq!(params.depth, 3);
766 }
767
768 #[tokio::test]
769 #[ignore = "requires fastembed model download — run with: cargo test -- --ignored test_cache_returns_cached_instance"]
770 async fn test_cache_returns_cached_instance() {
771 let tool = TldrTool::new();
772 let test_path = std::env::current_dir().unwrap();
773
774 let tldr1 = tool.get_tldr(&test_path).await.unwrap();
775 let tldr2 = tool.get_tldr(&test_path).await.unwrap();
776
777 let addr1 = Arc::as_ptr(&tldr1) as usize;
778 let addr2 = Arc::as_ptr(&tldr2) as usize;
779 assert_eq!(
780 addr1, addr2,
781 "Second call should return cached instance (same memory address)"
782 );
783 }
784}