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). low-cleanup-bundle-v1 (L6): the
143    // underlying type is now a BTreeMap (JSON object); iterate values
144    // sorted by total_lines descending so the table reads naturally.
145    if !report.by_language.is_empty() {
146        output.push_str("\nBy Language:\n");
147
148        let mut entries: Vec<&tldr_core::metrics::loc::LanguageLocEntry> =
149            report.by_language.values().collect();
150        entries.sort_by(|a, b| b.total_lines.cmp(&a.total_lines));
151
152        let max_lang = entries
153            .iter()
154            .map(|e| e.language.len())
155            .max()
156            .unwrap_or(8)
157            .max(8);
158        output.push_str(&format!(
159            "  {:<width$}  {:>5}  {:>6}  {:>6}  {:>5}  {:>6}\n",
160            "Language",
161            "Files",
162            "Code",
163            "Comment",
164            "Blank",
165            "Total",
166            width = max_lang,
167        ));
168
169        for entry in &entries {
170            output.push_str(&format!(
171                "  {:<width$}  {:>5}  {:>6}  {:>6}  {:>5}  {:>6}\n",
172                entry.language,
173                entry.files,
174                entry.code_lines,
175                entry.comment_lines,
176                entry.blank_lines,
177                entry.total_lines,
178                width = max_lang,
179            ));
180        }
181    }
182
183    // By file (if requested and present)
184    if let Some(by_file) = &report.by_file {
185        if !by_file.is_empty() {
186            output.push_str("\nBy File:\n");
187
188            // Strip common path prefix
189            let paths: Vec<&Path> = by_file.iter().map(|e| e.path.as_path()).collect();
190            let prefix = common_path_prefix(&paths);
191
192            let display_count = by_file.len().min(50);
193            let max_path = by_file
194                .iter()
195                .take(display_count)
196                .map(|e| strip_prefix_display(&e.path, &prefix).len())
197                .max()
198                .unwrap_or(4)
199                .clamp(4, 50);
200
201            output.push_str(&format!(
202                "  {:<width$}  {:>4}  {:>6}  {:>6}  {:>5}  {:>6}\n",
203                "File",
204                "Lang",
205                "Code",
206                "Comment",
207                "Blank",
208                "Total",
209                width = max_path,
210            ));
211
212            for entry in by_file.iter().take(display_count) {
213                let rel = strip_prefix_display(&entry.path, &prefix);
214                let display_path = if rel.len() > 50 {
215                    format!("...{}", &rel[rel.len() - 47..])
216                } else {
217                    rel
218                };
219                output.push_str(&format!(
220                    "  {:<width$}  {:>4}  {:>6}  {:>6}  {:>5}  {:>6}\n",
221                    display_path,
222                    entry.language,
223                    entry.code_lines,
224                    entry.comment_lines,
225                    entry.blank_lines,
226                    entry.total_lines,
227                    width = max_path,
228                ));
229            }
230
231            if by_file.len() > display_count {
232                output.push_str(&format!(
233                    "  ... and {} more files\n",
234                    by_file.len() - display_count
235                ));
236            }
237        }
238    }
239
240    // By directory (if requested and present)
241    if let Some(by_dir) = &report.by_directory {
242        if !by_dir.is_empty() {
243            output.push_str("\nBy Directory:\n");
244
245            let paths: Vec<&Path> = by_dir.iter().map(|e| e.path.as_path()).collect();
246            let prefix = common_path_prefix(&paths);
247
248            let max_dir = by_dir
249                .iter()
250                .take(30)
251                .map(|e| strip_prefix_display(&e.path, &prefix).len())
252                .max()
253                .unwrap_or(4)
254                .max(4);
255
256            output.push_str(&format!(
257                "  {:<width$}  {:>6}  {:>6}  {:>5}  {:>6}\n",
258                "Directory",
259                "Code",
260                "Comment",
261                "Blank",
262                "Total",
263                width = max_dir,
264            ));
265
266            for entry in by_dir.iter().take(30) {
267                let rel = strip_prefix_display(&entry.path, &prefix);
268                output.push_str(&format!(
269                    "  {:<width$}  {:>6}  {:>6}  {:>5}  {:>6}\n",
270                    rel,
271                    entry.code_lines,
272                    entry.comment_lines,
273                    entry.blank_lines,
274                    entry.total_lines,
275                    width = max_dir,
276                ));
277            }
278        }
279    }
280
281    // Warnings
282    if !report.warnings.is_empty() {
283        output.push_str(&"\nWarnings:\n".yellow().to_string());
284        for warning in &report.warnings {
285            output.push_str(&format!("  - {}\n", warning));
286        }
287    }
288
289    output
290}