Skip to main content

limit_cli/tools/
tldr.rs

1//! TLDR tool for code analysis.
2//!
3//! Provides a tool interface for the `limit-tldr` library, enabling agents
4//! to analyze code structure, dependencies, and complexity.
5
6use 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/// Analysis type to perform
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AnalysisType {
23    /// Get compressed context for a function (token-efficient)
24    Context,
25    /// Get source code of a function (use instead of file_read for implementation details)
26    Source,
27    /// Find who calls a function (impact analysis for refactoring)
28    Impact,
29    /// Get control flow graph (complexity analysis)
30    Cfg,
31    /// Get data flow graph (value tracking)
32    Dfg,
33    /// Find dead code (unreachable functions)
34    DeadCode,
35    /// Detect architecture layers (entry/middle/leaf)
36    Architecture,
37    /// Search functions by name pattern
38    Search,
39}
40
41/// Parameters for the TLDR tool
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct TldrParams {
44    /// Type of analysis to perform
45    pub analysis_type: AnalysisType,
46
47    /// Function name (required for context, impact, cfg, dfg)
48    pub function: Option<String>,
49
50    /// File path relative to project root (required for cfg, dfg)
51    pub file: Option<String>,
52
53    /// Depth for context traversal (default: 2)
54    #[serde(default = "default_depth")]
55    pub depth: usize,
56
57    /// Entry points for dead code detection (default: ["main"])
58    #[serde(default = "default_entries")]
59    pub entries: Vec<String>,
60
61    /// Search query for finding functions
62    pub query: Option<String>,
63
64    /// Maximum results for search (default: 10)
65    #[serde(default = "default_limit")]
66    pub limit: usize,
67
68    /// Project path (defaults to current directory)
69    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
82/// TLDR tool for code analysis
83pub struct TldrTool {
84    /// Cached TLDR instance (initialized once per session)
85    cache: Arc<OnceCell<(PathBuf, Arc<TLDR>)>>,
86    /// Default project path
87    default_project: PathBuf,
88    /// Notify waiters when background warm completes
89    warm_notify: Arc<Notify>,
90    /// Whether pre_warm has been spawned (lazy, once inside tokio runtime)
91    warm_started: Arc<AtomicBool>,
92}
93
94impl TldrTool {
95    /// Create a new TLDR tool with default project path
96    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    /// Create TLDR tool with a specific project path
107    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    /// Spawn pre_warm if not already started. Safe to call inside tokio runtime.
117    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    /// Background warm: check freshness, warm if stale, notify waiters.
134    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    /// Get or create TLDR instance for a project (thread-safe, initializes once).
180    ///
181    /// If pre_warm is still running, waits for it. Falls back to lazy creation
182    /// if pre_warm fails or hasn't started.
183    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        // Kick off pre_warm on first call inside the tokio runtime
188        self.ensure_pre_warm_started();
189
190        // If cache already populated, return immediately
191        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        // Wait for background warm to finish (with timeout fallback)
203        info!("get_tldr: waiting for pre_warm...");
204        tokio::select! {
205            _ = self.warm_notify.notified() => {
206                // pre_warm finished — check if it succeeded
207                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                // pre_warm failed or was skipped (fresh) — fall through to lazy
214                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        // Lazy fallback
222        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    /// Get cache directory for a project (~/.limit/projects/<project-hash>/tldr)
257    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        // Create a unique identifier for the project
262        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        // Simple hash of project path
269        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    /// Build a source result JSON, reading the file and extracting lines
283    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    /// Perform analysis based on parameters
327    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                // Handle qualified method names: "StructName::method" (Rust/JS)
362                // Resolve the struct to its file, then search for the method name alone
363                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                // Use resolved file if available, otherwise keep original
395                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                    // Struct/class lookup
406                    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                    // Function lookup — also try struct/class fallback
430                    let func_info = if let Some(ref file) = effective_file {
431                        let file_path = project_path.join(file);
432                        // Try function first, then struct fallback
433                        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                            // Found as struct — treat as struct lookup
445                            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                        // Try find_all to detect ambiguity
462                        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                // Return counts + samples to keep output small
595                // Full lists can be huge (20k+ chars) defeating token efficiency goal
596                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        // Parse parameters
663        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) = &params.function {
668            debug!("  function: {}", f);
669        }
670        if let Some(ref q) = &params.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
692/// Generate tool definition for LLM providers
693pub 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}