Skip to main content

tldr_cli/commands/
loc.rs

1//! LOC command - Count lines of code with type breakdown
2//!
3//! Provides language-aware line counting:
4//! - Code lines: Lines containing executable code
5//! - Comment lines: Lines containing only comments
6//! - Blank lines: Empty lines or lines with only whitespace
7//!
8//! # Session 15 Phase 2
9//!
10//! Implements spec.md Section 1 (LOC Command).
11//!
12//! # Invariants
13//!
14//! - `code_lines + comment_lines + blank_lines == total_lines`
15//! - Binary files are skipped with warning
16//! - Files > 10MB are skipped with warning
17//!
18//! # Example
19//!
20//! ```bash
21//! # Analyze a single file
22//! tldr loc src/main.rs
23//!
24//! # Analyze a directory with per-file breakdown
25//! tldr loc src/ --by-file
26//!
27//! # Filter by language
28//! tldr loc . --lang python
29//!
30//! # Exclude patterns
31//! tldr loc . --exclude "*.test.py" --exclude "migrations/*"
32//! ```
33
34use std::path::PathBuf;
35
36use anyhow::Result;
37use clap::Args;
38
39use tldr_core::metrics::loc::{analyze_loc, LocOptions, LocReport};
40use tldr_core::Language;
41
42use crate::output::{OutputFormat, OutputWriter};
43
44/// Count lines of code with type breakdown (code, comments, blanks)
45#[derive(Debug, Args)]
46pub struct LocArgs {
47    /// Directory or file to analyze
48    #[arg(default_value = ".")]
49    pub path: PathBuf,
50
51    /// Filter to specific language
52    #[arg(long, short = 'l')]
53    pub lang: Option<Language>,
54
55    /// Show per-file breakdown
56    #[arg(long)]
57    pub by_file: bool,
58
59    /// Aggregate by directory
60    #[arg(long)]
61    pub by_dir: bool,
62
63    /// Exclude patterns (glob syntax), can be specified multiple times
64    #[arg(long, short = 'e')]
65    pub exclude: Vec<String>,
66
67    /// Include hidden files (dotfiles)
68    #[arg(long)]
69    pub include_hidden: bool,
70
71    /// Ignore .gitignore rules
72    #[arg(long)]
73    pub no_gitignore: bool,
74
75    /// Maximum files to process (0 = unlimited)
76    #[arg(long, default_value = "0")]
77    pub max_files: usize,
78}
79
80impl LocArgs {
81    /// Run the LOC command
82    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
83        let writer = OutputWriter::new(format, quiet);
84
85        writer.progress(&format!("Counting lines in {}...", self.path.display()));
86
87        // Build options
88        let options = LocOptions {
89            lang: self.lang,
90            by_file: self.by_file,
91            by_dir: self.by_dir,
92            exclude: self.exclude.clone(),
93            include_hidden: self.include_hidden,
94            gitignore: !self.no_gitignore,
95            max_files: self.max_files,
96            max_file_size_mb: 10, // Default 10MB limit
97        };
98
99        // Analyze
100        let report = analyze_loc(&self.path, &options)?;
101
102        // Output based on format
103        if writer.is_text() {
104            let text = format_loc_text(&report);
105            writer.write_text(&text)?;
106        } else {
107            writer.write(&report)?;
108        }
109
110        Ok(())
111    }
112}
113
114/// Format LOC report for human-readable text output.
115/// Uses plain aligned text (no box-drawing tables) for token efficiency.
116fn format_loc_text(report: &LocReport) -> String {
117    use crate::output::{common_path_prefix, strip_prefix_display};
118    use colored::Colorize;
119    use std::path::Path;
120
121    let mut output = String::new();
122
123    // Summary
124    let summary = &report.summary;
125    output.push_str(&format!(
126        "Lines of Code ({} files, {} total)\n\n",
127        summary.total_files, summary.total_lines,
128    ));
129    output.push_str(&format!(
130        "  Code:     {:>6} ({:.1}%)\n",
131        summary.code_lines, summary.code_percent
132    ));
133    output.push_str(&format!(
134        "  Comments: {:>6} ({:.1}%)\n",
135        summary.comment_lines, summary.comment_percent
136    ));
137    output.push_str(&format!(
138        "  Blank:    {:>6} ({:.1}%)\n",
139        summary.blank_lines, summary.blank_percent
140    ));
141
142    // By language (plain text table)
143    if !report.by_language.is_empty() {
144        output.push_str("\nBy Language:\n");
145
146        let max_lang = report
147            .by_language
148            .iter()
149            .map(|e| e.language.len())
150            .max()
151            .unwrap_or(8)
152            .max(8);
153        output.push_str(&format!(
154            "  {:<width$}  {:>5}  {:>6}  {:>6}  {:>5}  {:>6}\n",
155            "Language",
156            "Files",
157            "Code",
158            "Comment",
159            "Blank",
160            "Total",
161            width = max_lang,
162        ));
163
164        for entry in &report.by_language {
165            output.push_str(&format!(
166                "  {:<width$}  {:>5}  {:>6}  {:>6}  {:>5}  {:>6}\n",
167                entry.language,
168                entry.files,
169                entry.code_lines,
170                entry.comment_lines,
171                entry.blank_lines,
172                entry.total_lines,
173                width = max_lang,
174            ));
175        }
176    }
177
178    // By file (if requested and present)
179    if let Some(by_file) = &report.by_file {
180        if !by_file.is_empty() {
181            output.push_str("\nBy File:\n");
182
183            // Strip common path prefix
184            let paths: Vec<&Path> = by_file.iter().map(|e| e.path.as_path()).collect();
185            let prefix = common_path_prefix(&paths);
186
187            let display_count = by_file.len().min(50);
188            let max_path = by_file
189                .iter()
190                .take(display_count)
191                .map(|e| strip_prefix_display(&e.path, &prefix).len())
192                .max()
193                .unwrap_or(4)
194                .clamp(4, 50);
195
196            output.push_str(&format!(
197                "  {:<width$}  {:>4}  {:>6}  {:>6}  {:>5}  {:>6}\n",
198                "File",
199                "Lang",
200                "Code",
201                "Comment",
202                "Blank",
203                "Total",
204                width = max_path,
205            ));
206
207            for entry in by_file.iter().take(display_count) {
208                let rel = strip_prefix_display(&entry.path, &prefix);
209                let display_path = if rel.len() > 50 {
210                    format!("...{}", &rel[rel.len() - 47..])
211                } else {
212                    rel
213                };
214                output.push_str(&format!(
215                    "  {:<width$}  {:>4}  {:>6}  {:>6}  {:>5}  {:>6}\n",
216                    display_path,
217                    entry.language,
218                    entry.code_lines,
219                    entry.comment_lines,
220                    entry.blank_lines,
221                    entry.total_lines,
222                    width = max_path,
223                ));
224            }
225
226            if by_file.len() > display_count {
227                output.push_str(&format!(
228                    "  ... and {} more files\n",
229                    by_file.len() - display_count
230                ));
231            }
232        }
233    }
234
235    // By directory (if requested and present)
236    if let Some(by_dir) = &report.by_directory {
237        if !by_dir.is_empty() {
238            output.push_str("\nBy Directory:\n");
239
240            let paths: Vec<&Path> = by_dir.iter().map(|e| e.path.as_path()).collect();
241            let prefix = common_path_prefix(&paths);
242
243            let max_dir = by_dir
244                .iter()
245                .take(30)
246                .map(|e| strip_prefix_display(&e.path, &prefix).len())
247                .max()
248                .unwrap_or(4)
249                .max(4);
250
251            output.push_str(&format!(
252                "  {:<width$}  {:>6}  {:>6}  {:>5}  {:>6}\n",
253                "Directory",
254                "Code",
255                "Comment",
256                "Blank",
257                "Total",
258                width = max_dir,
259            ));
260
261            for entry in by_dir.iter().take(30) {
262                let rel = strip_prefix_display(&entry.path, &prefix);
263                output.push_str(&format!(
264                    "  {:<width$}  {:>6}  {:>6}  {:>5}  {:>6}\n",
265                    rel,
266                    entry.code_lines,
267                    entry.comment_lines,
268                    entry.blank_lines,
269                    entry.total_lines,
270                    width = max_dir,
271                ));
272            }
273        }
274    }
275
276    // Warnings
277    if !report.warnings.is_empty() {
278        output.push_str(&"\nWarnings:\n".yellow().to_string());
279        for warning in &report.warnings {
280            output.push_str(&format!("  - {}\n", warning));
281        }
282    }
283
284    output
285}