Skip to main content

tldr_cli/commands/remaining/
todo.rs

1//! Todo command - Improvement aggregator
2//!
3//! Aggregates improvement suggestions from multiple sub-analyses:
4//! - Dead code analysis (existing `tldr dead`)
5//! - Complexity analysis (existing `tldr complexity`)
6//! - Cohesion analysis (existing `tldr cohesion`)
7//! - Equivalence analysis (implement later, stub for now)
8//! - Similar code analysis (existing `tldr similar`)
9//!
10//! # Example
11//!
12//! ```bash
13//! tldr todo src/
14//! tldr todo src/main.py --quick
15//! tldr todo src/ --detail dead --format text
16//! ```
17
18use std::collections::HashMap;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::time::Instant;
22
23use anyhow::Result;
24use clap::Args;
25use serde_json::Value;
26use tldr_core::walker::ProjectWalker;
27
28use super::ast_cache::AstCache;
29use super::error::{RemainingError, RemainingResult};
30use super::types::{TodoItem, TodoReport, TodoSummary};
31
32use crate::output::OutputWriter;
33
34// Import existing analysis modules
35use crate::commands::dead::collect_module_infos_with_refcounts;
36use tldr_core::analysis::dead::dead_code_analysis_refcount;
37use tldr_core::{collect_all_functions, get_code_structure, FunctionRef, IgnoreSpec, Language};
38
39// =============================================================================
40// Constants
41// =============================================================================
42
43/// Priority levels for different categories
44const PRIORITY_DEAD_CODE: u32 = 1;
45const PRIORITY_COMPLEXITY: u32 = 2;
46const PRIORITY_COHESION: u32 = 3;
47const PRIORITY_EQUIVALENCE: u32 = 4;
48const PRIORITY_SIMILAR: u32 = 5;
49
50// =============================================================================
51// Sub-Analysis Enum
52// =============================================================================
53
54/// Types of sub-analyses that todo command orchestrates
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SubAnalysis {
57    Dead,
58    Complexity,
59    Cohesion,
60    Equivalence,
61    Similar,
62}
63
64impl SubAnalysis {
65    /// Get all analyses for full mode
66    pub fn all() -> &'static [SubAnalysis] {
67        &[
68            SubAnalysis::Dead,
69            SubAnalysis::Complexity,
70            SubAnalysis::Cohesion,
71            SubAnalysis::Equivalence,
72            SubAnalysis::Similar,
73        ]
74    }
75
76    /// Get analyses for quick mode (skip similar which is slowest)
77    pub fn quick() -> &'static [SubAnalysis] {
78        &[
79            SubAnalysis::Dead,
80            SubAnalysis::Complexity,
81            SubAnalysis::Cohesion,
82            SubAnalysis::Equivalence,
83        ]
84    }
85
86    /// Get the priority for this analysis type
87    pub fn priority(&self) -> u32 {
88        match self {
89            SubAnalysis::Dead => PRIORITY_DEAD_CODE,
90            SubAnalysis::Complexity => PRIORITY_COMPLEXITY,
91            SubAnalysis::Cohesion => PRIORITY_COHESION,
92            SubAnalysis::Equivalence => PRIORITY_EQUIVALENCE,
93            SubAnalysis::Similar => PRIORITY_SIMILAR,
94        }
95    }
96
97    /// Get the category name for this analysis
98    pub fn category(&self) -> &'static str {
99        match self {
100            SubAnalysis::Dead => "dead_code",
101            SubAnalysis::Complexity => "complexity",
102            SubAnalysis::Cohesion => "cohesion",
103            SubAnalysis::Equivalence => "equivalence",
104            SubAnalysis::Similar => "similar",
105        }
106    }
107}
108
109impl std::str::FromStr for SubAnalysis {
110    type Err = String;
111
112    fn from_str(s: &str) -> Result<Self, Self::Err> {
113        match s.to_lowercase().as_str() {
114            "dead" | "dead_code" => Ok(SubAnalysis::Dead),
115            "complexity" | "complex" => Ok(SubAnalysis::Complexity),
116            "cohesion" | "lcom4" => Ok(SubAnalysis::Cohesion),
117            "equivalence" | "equiv" | "gvn" => Ok(SubAnalysis::Equivalence),
118            "similar" | "sim" => Ok(SubAnalysis::Similar),
119            _ => Err(format!("Unknown analysis: {}", s)),
120        }
121    }
122}
123
124// =============================================================================
125// CLI Arguments
126// =============================================================================
127
128/// Aggregate improvement suggestions from multiple analyses
129///
130/// Runs dead code, complexity, cohesion, equivalence, and similar code analyses,
131/// then aggregates findings into a priority-sorted list of improvement items.
132///
133/// # Example
134///
135/// ```bash
136/// tldr todo src/
137/// tldr todo src/main.py --quick
138/// tldr todo src/ --detail dead
139/// ```
140#[derive(Debug, Args)]
141pub struct TodoArgs {
142    /// File or directory to analyze
143    pub path: PathBuf,
144
145    /// Show details for specific sub-analysis
146    #[arg(long)]
147    pub detail: Option<String>,
148
149    /// Run quick mode (skip similar analysis)
150    #[arg(long)]
151    pub quick: bool,
152
153    /// Maximum number of items to display (0 = show all)
154    #[arg(long, default_value = "20")]
155    pub max_items: usize,
156
157    /// Output file (optional, stdout if not specified)
158    #[arg(long, short = 'O')]
159    pub output: Option<PathBuf>,
160}
161
162impl TodoArgs {
163    /// Run the todo command
164    pub fn run(
165        &self,
166        format: crate::output::OutputFormat,
167        quiet: bool,
168        lang: Option<Language>,
169    ) -> Result<()> {
170        let writer = OutputWriter::new(format, quiet);
171        let start = Instant::now();
172
173        writer.progress(&format!(
174            "Analyzing {} for improvements...",
175            self.path.display()
176        ));
177
178        // Validate path exists
179        if !self.path.exists() {
180            return Err(RemainingError::file_not_found(&self.path).into());
181        }
182
183        // Determine language from CLI option or auto-detect
184        let language = if let Some(l) = lang {
185            l
186        } else {
187            detect_language(&self.path)?
188        };
189
190        // Create AST cache for shared parsing
191        let mut cache = AstCache::default();
192
193        // Determine which analyses to run
194        let analyses = if self.quick {
195            SubAnalysis::quick()
196        } else {
197            SubAnalysis::all()
198        };
199
200        // Run sub-analyses and collect results
201        let mut sub_results: HashMap<String, Value> = HashMap::new();
202        let mut all_items: Vec<TodoItem> = Vec::new();
203        let mut summary = TodoSummary::default();
204
205        for analysis in analyses {
206            writer.progress(&format!("Running {} analysis...", analysis.category()));
207
208            match run_sub_analysis(*analysis, &self.path, language, &mut cache) {
209                Ok((items, result_value)) => {
210                    // Update summary
211                    update_summary(&mut summary, *analysis, &items);
212
213                    // Store raw results if detail requested (match by parsing the detail arg)
214                    if let Some(ref detail) = self.detail {
215                        if let Ok(detail_analysis) = detail.parse::<SubAnalysis>() {
216                            if detail_analysis == *analysis {
217                                sub_results.insert(analysis.category().to_string(), result_value);
218                            }
219                        }
220                    }
221
222                    // Add items to aggregate list
223                    all_items.extend(items);
224                }
225                Err(e) => {
226                    // Log error but continue with other analyses
227                    writer.progress(&format!(
228                        "Warning: {} analysis failed: {}",
229                        analysis.category(),
230                        e
231                    ));
232                }
233            }
234        }
235
236        // Sort items by priority
237        all_items.sort_by_key(|item| item.priority);
238
239        // Apply max_items truncation
240        let total_items = all_items.len();
241        let truncated = self.max_items > 0 && total_items > self.max_items;
242        if truncated {
243            all_items.truncate(self.max_items);
244        }
245
246        // Build report
247        let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
248        let report = TodoReport {
249            wrapper: "todo".to_string(),
250            path: self.path.display().to_string(),
251            items: all_items,
252            summary,
253            sub_results,
254            total_elapsed_ms: elapsed_ms,
255        };
256
257        // Write output
258        if let Some(ref output_path) = self.output {
259            // Write to file based on format
260            if writer.is_text() {
261                let text = format_todo_text(&report, truncated, total_items);
262                fs::write(output_path, text)?;
263            } else {
264                let json = serde_json::to_string_pretty(&report)?;
265                fs::write(output_path, json)?;
266            }
267        } else {
268            // Write to stdout
269            if writer.is_text() {
270                let text = format_todo_text(&report, truncated, total_items);
271                writer.write_text(&text)?;
272            } else {
273                writer.write(&report)?;
274            }
275        }
276
277        Ok(())
278    }
279}
280
281// =============================================================================
282// Sub-Analysis Runners
283// =============================================================================
284
285/// Run a sub-analysis and return items + raw result
286fn run_sub_analysis(
287    analysis: SubAnalysis,
288    path: &Path,
289    language: Language,
290    _cache: &mut AstCache,
291) -> RemainingResult<(Vec<TodoItem>, Value)> {
292    match analysis {
293        SubAnalysis::Dead => run_dead_analysis(path, language),
294        SubAnalysis::Complexity => run_complexity_analysis(path, language),
295        SubAnalysis::Cohesion => run_cohesion_analysis(path),
296        SubAnalysis::Equivalence => run_equivalence_analysis(path),
297        SubAnalysis::Similar => run_similar_analysis(path),
298    }
299}
300
301/// Run dead code analysis using reference counting (low false-positive rate)
302fn run_dead_analysis(path: &Path, language: Language) -> RemainingResult<(Vec<TodoItem>, Value)> {
303    // For single files, use parent directory for scanning (needs directory context)
304    let project_root = if path.is_file() {
305        path.parent().unwrap_or(path)
306    } else {
307        path
308    };
309
310    // Single-pass: collect module infos and identifier reference counts together
311    let (module_infos, merged_ref_counts) =
312        collect_module_infos_with_refcounts(project_root, language, false);
313    let all_functions: Vec<FunctionRef> = collect_all_functions(&module_infos);
314
315    // Run refcount-based analysis (rescues functions that are referenced by name)
316    let report = dead_code_analysis_refcount(&all_functions, &merged_ref_counts, None)
317        .map_err(|e| RemainingError::analysis_error(format!("Dead code analysis failed: {}", e)))?;
318
319    // Convert to TodoItems
320    let items: Vec<TodoItem> = report
321        .dead_functions
322        .iter()
323        .map(|func| {
324            TodoItem::new(
325                "dead_code",
326                PRIORITY_DEAD_CODE,
327                format!("Unused function: {}", func.name),
328            )
329            .with_location(func.file.display().to_string(), 0)
330            .with_severity("medium")
331        })
332        .collect();
333
334    let result_value = serde_json::to_value(&report).unwrap_or(Value::Null);
335
336    Ok((items, result_value))
337}
338
339/// Run complexity analysis (hotspots)
340fn run_complexity_analysis(
341    path: &Path,
342    language: Language,
343) -> RemainingResult<(Vec<TodoItem>, Value)> {
344    // Get structure to find functions
345    let structure = get_code_structure(path, language, 0, Some(&IgnoreSpec::default()))
346        .map_err(|e| RemainingError::analysis_error(format!("Failed to get structure: {}", e)))?;
347
348    let mut items = Vec::new();
349
350    // Check each function for high complexity (threshold: cyclomatic > 10)
351    for file in &structure.files {
352        for func_name in &file.functions {
353            let file_path = path.join(&file.path);
354            if let Ok(metrics) = tldr_core::calculate_complexity(
355                file_path.to_str().unwrap_or_default(),
356                func_name,
357                language,
358            ) {
359                if metrics.cyclomatic > 10 {
360                    items.push(
361                        TodoItem::new(
362                            "complexity",
363                            PRIORITY_COMPLEXITY,
364                            format!(
365                                "High complexity in {}: cyclomatic={}, consider refactoring",
366                                func_name, metrics.cyclomatic
367                            ),
368                        )
369                        .with_location(file.path.display().to_string(), 1)
370                        .with_severity(if metrics.cyclomatic > 20 {
371                            "high"
372                        } else {
373                            "medium"
374                        })
375                        .with_score(metrics.cyclomatic as f64 / 50.0),
376                    );
377                }
378            }
379        }
380    }
381
382    let result_value = serde_json::json!({
383        "hotspots": items.len(),
384        "threshold": 10
385    });
386
387    Ok((items, result_value))
388}
389
390/// Run cohesion analysis (LCOM4)
391fn run_cohesion_analysis(path: &Path) -> RemainingResult<(Vec<TodoItem>, Value)> {
392    use crate::commands::patterns::cohesion::{run as run_cohesion, CohesionArgs};
393
394    let args = CohesionArgs {
395        path: path.to_path_buf(),
396        min_methods: 1,
397        include_dunder: false,
398        output_format: crate::commands::patterns::cohesion::OutputFormat::Json,
399        timeout: 30,
400        project_root: None,
401        lang: None,
402    };
403
404    let report = run_cohesion(args)
405        .map_err(|e| RemainingError::analysis_error(format!("Cohesion analysis failed: {}", e)))?;
406
407    let items: Vec<TodoItem> = report
408        .classes
409        .iter()
410        .filter(|c| c.lcom4 > 1)
411        .map(|c| {
412            TodoItem::new(
413                "cohesion",
414                PRIORITY_COHESION,
415                format!(
416                    "Low cohesion in class {}: LCOM4={}, consider splitting",
417                    c.class_name, c.lcom4
418                ),
419            )
420            .with_location(c.file_path.clone(), c.line)
421            .with_severity(if c.lcom4 > 3 { "high" } else { "medium" })
422            .with_score(c.lcom4 as f64 / 5.0)
423        })
424        .collect();
425
426    let result_value = serde_json::to_value(&report).unwrap_or(Value::Null);
427
428    Ok((items, result_value))
429}
430
431/// Run equivalence analysis (GVN - stub for now)
432fn run_equivalence_analysis(_path: &Path) -> RemainingResult<(Vec<TodoItem>, Value)> {
433    // TODO: Implement GVN equivalence detection in Phase 9
434    // For now, return empty results
435    let result_value = serde_json::json!({
436        "status": "not_implemented",
437        "message": "GVN equivalence analysis will be implemented in Phase 9"
438    });
439
440    Ok((Vec::new(), result_value))
441}
442
443/// Run similar code analysis (stub - uses semantic search)
444fn run_similar_analysis(_path: &Path) -> RemainingResult<(Vec<TodoItem>, Value)> {
445    // TODO: Integrate with tldr similar command
446    // For now, return empty results as similar analysis is expensive
447    let result_value = serde_json::json!({
448        "status": "skipped",
449        "message": "Similar code analysis is expensive, consider using 'tldr similar' directly"
450    });
451
452    Ok((Vec::new(), result_value))
453}
454
455// =============================================================================
456// Helper Functions
457// =============================================================================
458
459/// Detect language from path (auto-detect from extension or directory contents)
460fn detect_language(path: &Path) -> RemainingResult<Language> {
461    // Auto-detect from file extension or directory contents
462    if path.is_file() {
463        let ext = path
464            .extension()
465            .and_then(|e| e.to_str())
466            .unwrap_or_default();
467
468        match ext {
469            "py" => Ok(Language::Python),
470            "ts" | "tsx" => Ok(Language::TypeScript),
471            "js" | "jsx" => Ok(Language::JavaScript),
472            "rs" => Ok(Language::Rust),
473            "go" => Ok(Language::Go),
474            _ => Err(RemainingError::unsupported_language(ext)),
475        }
476    } else if path.is_dir() {
477        // Check for common files to detect language
478        for entry in ProjectWalker::new(path).max_depth(2).iter() {
479            if let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) {
480                match ext {
481                    "py" => return Ok(Language::Python),
482                    "ts" | "tsx" => return Ok(Language::TypeScript),
483                    "js" | "jsx" => return Ok(Language::JavaScript),
484                    "rs" => return Ok(Language::Rust),
485                    "go" => return Ok(Language::Go),
486                    _ => continue,
487                }
488            }
489        }
490        // Default to Python if no language detected
491        Ok(Language::Python)
492    } else {
493        Err(RemainingError::file_not_found(path))
494    }
495}
496
497/// Update summary based on analysis results
498fn update_summary(summary: &mut TodoSummary, analysis: SubAnalysis, items: &[TodoItem]) {
499    match analysis {
500        SubAnalysis::Dead => summary.dead_count = items.len() as u32,
501        SubAnalysis::Complexity => summary.hotspot_count = items.len() as u32,
502        SubAnalysis::Cohesion => summary.low_cohesion_count = items.len() as u32,
503        SubAnalysis::Equivalence => summary.equivalence_groups = items.len() as u32,
504        SubAnalysis::Similar => summary.similar_pairs = items.len() as u32,
505    }
506}
507
508/// Format todo report as human-readable text
509///
510/// When `truncated` is true, a footer message is appended indicating how many
511/// items were omitted and how to see all of them. `total_items` is the count
512/// before truncation.
513pub fn format_todo_text(report: &TodoReport, truncated: bool, total_items: usize) -> String {
514    let mut lines = Vec::new();
515
516    lines.push(format!("TODO Report for: {}", report.path));
517    lines.push(format!("Total items: {}", total_items));
518    lines.push(String::new());
519
520    // Summary
521    lines.push("Summary:".to_string());
522    lines.push(format!("  Dead code items: {}", report.summary.dead_count));
523    lines.push(format!(
524        "  Complexity hotspots: {}",
525        report.summary.hotspot_count
526    ));
527    lines.push(format!(
528        "  Low cohesion classes: {}",
529        report.summary.low_cohesion_count
530    ));
531    lines.push(format!(
532        "  Similar code pairs: {}",
533        report.summary.similar_pairs
534    ));
535    lines.push(format!(
536        "  Equivalence groups: {}",
537        report.summary.equivalence_groups
538    ));
539    lines.push(String::new());
540
541    if report.items.is_empty() {
542        lines.push("No improvement items found.".to_string());
543    } else {
544        lines.push("Items (sorted by priority):".to_string());
545        lines.push(String::new());
546
547        for (i, item) in report.items.iter().enumerate() {
548            lines.push(format!(
549                "{}. [{}] {} (priority: {})",
550                i + 1,
551                item.category,
552                item.description,
553                item.priority
554            ));
555
556            if !item.file.is_empty() {
557                lines.push(format!("   Location: {}:{}", item.file, item.line));
558            }
559
560            if !item.severity.is_empty() {
561                lines.push(format!("   Severity: {}", item.severity));
562            }
563        }
564
565        if truncated {
566            let remaining = total_items - report.items.len();
567            lines.push(String::new());
568            lines.push(format!(
569                "... and {} more items. Use --max-items 0 to show all.",
570                remaining
571            ));
572        }
573    }
574
575    lines.push(String::new());
576    lines.push(format!("Analysis time: {:.2}ms", report.total_elapsed_ms));
577
578    lines.join("\n")
579}
580
581// =============================================================================
582// Tests
583// =============================================================================
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn test_sub_analysis_from_str() {
591        assert_eq!("dead".parse::<SubAnalysis>().unwrap(), SubAnalysis::Dead);
592        assert_eq!(
593            "complexity".parse::<SubAnalysis>().unwrap(),
594            SubAnalysis::Complexity
595        );
596        assert_eq!(
597            "cohesion".parse::<SubAnalysis>().unwrap(),
598            SubAnalysis::Cohesion
599        );
600        assert!("unknown".parse::<SubAnalysis>().is_err());
601    }
602
603    #[test]
604    fn test_sub_analysis_priority() {
605        assert!(SubAnalysis::Dead.priority() < SubAnalysis::Complexity.priority());
606        assert!(SubAnalysis::Complexity.priority() < SubAnalysis::Cohesion.priority());
607    }
608
609    #[test]
610    fn test_quick_mode_skips_similar() {
611        let quick = SubAnalysis::quick();
612        let all = SubAnalysis::all();
613
614        assert!(quick.len() < all.len());
615        assert!(!quick.contains(&SubAnalysis::Similar));
616        assert!(all.contains(&SubAnalysis::Similar));
617    }
618
619    #[test]
620    fn test_format_todo_text() {
621        let mut report = TodoReport::new("/path/to/project");
622        report
623            .items
624            .push(TodoItem::new("dead_code", 1, "Unused function"));
625        report.summary.dead_count = 1;
626        report.total_elapsed_ms = 100.5;
627
628        let text = format_todo_text(&report, false, 1);
629        assert!(text.contains("TODO Report"));
630        assert!(text.contains("Dead code items: 1"));
631        assert!(text.contains("Unused function"));
632    }
633
634    #[test]
635    fn test_todo_args_max_items_default() {
636        // Default max_items should be 20
637        use clap::Parser;
638
639        #[derive(Debug, Parser)]
640        struct Wrapper {
641            #[command(flatten)]
642            todo: TodoArgs,
643        }
644
645        let w = Wrapper::parse_from(["test", "src/"]);
646        assert_eq!(w.todo.max_items, 20, "default max_items should be 20");
647    }
648
649    #[test]
650    fn test_todo_args_max_items_flag() {
651        // --max-items 10 should parse correctly
652        use clap::Parser;
653
654        #[derive(Debug, Parser)]
655        struct Wrapper {
656            #[command(flatten)]
657            todo: TodoArgs,
658        }
659
660        let w = Wrapper::parse_from(["test", "src/", "--max-items", "10"]);
661        assert_eq!(w.todo.max_items, 10);
662    }
663
664    #[test]
665    fn test_todo_output_respects_max_items() {
666        // When max_items is set, format_todo_text should only show that many items
667        let mut report = TodoReport::new("/path/to/project");
668        for i in 0..20 {
669            report.items.push(TodoItem::new(
670                "dead_code",
671                1,
672                format!("Unused function: fn_{}", i),
673            ));
674        }
675        report.summary.dead_count = 20;
676        report.total_elapsed_ms = 50.0;
677
678        // Apply max_items=5 truncation
679        let max_items: usize = 5;
680        let total = report.items.len();
681        let truncated = total > max_items && max_items > 0;
682        if truncated {
683            report.items.truncate(max_items);
684        }
685
686        let text = format_todo_text(&report, truncated, total);
687        // Should contain exactly 5 numbered items
688        assert!(text.contains("1. [dead_code]"));
689        assert!(text.contains("5. [dead_code]"));
690        assert!(!text.contains("6. [dead_code]"));
691        // Should contain truncation message
692        assert!(text.contains("... and 15 more items"));
693        assert!(text.contains("--max-items 0"));
694    }
695
696    #[test]
697    fn test_todo_output_no_truncation_message_when_not_truncated() {
698        let mut report = TodoReport::new("/path/to/project");
699        for i in 0..3 {
700            report.items.push(TodoItem::new(
701                "dead_code",
702                1,
703                format!("Unused function: fn_{}", i),
704            ));
705        }
706        report.summary.dead_count = 3;
707        report.total_elapsed_ms = 10.0;
708
709        let text = format_todo_text(&report, false, 3);
710        assert!(!text.contains("... and"));
711        assert!(!text.contains("--max-items"));
712    }
713
714    #[test]
715    fn test_detect_language_from_extension() {
716        use std::fs::File;
717        use tempfile::TempDir;
718
719        let temp = TempDir::new().unwrap();
720        let py_file = temp.path().join("test.py");
721        File::create(&py_file).unwrap();
722
723        let lang = detect_language(&py_file).unwrap();
724        assert_eq!(lang, Language::Python);
725    }
726
727    #[test]
728    fn test_run_dead_analysis_uses_refcount() {
729        // Verify run_dead_analysis uses the refcount-based analyzer (not old call-graph).
730        // Create a minimal Python project with one "dead" function.
731        use std::fs;
732        use tempfile::TempDir;
733
734        let temp = TempDir::new().unwrap();
735        let py_file = temp.path().join("sample.py");
736        // _dead_func is private (leading underscore) and only appears once (definition),
737        // so refcount=1 -> dead. used_func appears twice (def + call), so refcount=2 -> alive.
738        fs::write(
739            &py_file,
740            "def used_func():\n    pass\n\ndef _dead_func():\n    pass\n\nused_func()\n",
741        )
742        .unwrap();
743
744        let (items, value) = run_dead_analysis(temp.path(), Language::Python).unwrap();
745        // The refcount analyzer should find _dead_func as dead (private, ref_count=1)
746        // but not used_func (ref_count=2, rescued by refcount).
747        let dead_names: Vec<&str> = items.iter().map(|i| i.description.as_str()).collect();
748        assert!(
749            dead_names.iter().any(|d| d.contains("_dead_func")),
750            "Expected _dead_func to be reported as dead, got: {:?}",
751            dead_names
752        );
753        assert!(
754            !dead_names.iter().any(|d| d.contains("used_func")),
755            "used_func should NOT be reported as dead, got: {:?}",
756            dead_names
757        );
758        // The result value should serialize successfully
759        assert!(!value.is_null(), "Expected non-null result value");
760    }
761}