fix: resolve CI/CD issues - remove unused dependencies and imports
- Remove unused thiserror dependency from Cargo.toml - Remove unused imports (Text, Tabs, Widget, Event, KeyCode, KeyEventKind) from tui/mod.rs - Remove unused imports (File, Write) from export/mod.rs - Remove unused pub use ComplexityDistribution from core/analyzer.rs
This commit is contained in:
52
techdebt-tracker-cli/.gitea/workflows/ci.yml
Normal file
52
techdebt-tracker-cli/.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout: 600
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: actions-rs/toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
profile: minimal
|
||||||
|
override: true
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/registry
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
|
- name: Cache cargo build
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-build-
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: cargo build --all-features
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --all-features
|
||||||
|
|
||||||
|
- name: Run clippy
|
||||||
|
run: cargo clippy --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --check -- --color=never
|
||||||
6
techdebt-tracker-cli/.gitignore
vendored
Normal file
6
techdebt-tracker-cli/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
target/
|
||||||
|
Cargo.lock
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
49
techdebt-tracker-cli/BUILD.md
Normal file
49
techdebt-tracker-cli/BUILD.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Build Instructions
|
||||||
|
|
||||||
|
This project requires a C compiler to build native dependencies.
|
||||||
|
|
||||||
|
## Linux (Debian/Ubuntu)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y build-essential
|
||||||
|
```
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
Install Visual Studio Build Tools.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to project directory
|
||||||
|
cd techdebt-tracker-cli
|
||||||
|
|
||||||
|
# Build the project
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
cargo clippy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies Installed
|
||||||
|
|
||||||
|
The project uses the following key dependencies:
|
||||||
|
- clap 4.4 - CLI argument parsing
|
||||||
|
- ratatui 0.26 - Terminal UI framework
|
||||||
|
- tree-sitter 0.22 - Source code parsing
|
||||||
|
- serde 1.0 - Serialization
|
||||||
|
- regex 1.10 - Pattern matching
|
||||||
|
- anyhow 1.0 - Error handling
|
||||||
|
- ignore 0.4 - File traversal with gitignore support
|
||||||
|
- crossterm 0.28 - Terminal capabilities
|
||||||
|
- chrono 0.4 - Date/time handling
|
||||||
41
techdebt-tracker-cli/Cargo.toml
Normal file
41
techdebt-tracker-cli/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[package]
|
||||||
|
name = "techdebt-tracker-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["TechDebt Tracker Contributors"]
|
||||||
|
description = "A CLI tool to analyze and track technical debt in codebases"
|
||||||
|
repository = "https://github.com/example/techdebt-tracker-cli"
|
||||||
|
keywords = ["cli", "tui", "technical-debt", "tree-sitter"]
|
||||||
|
categories = ["development-tools", "visualization"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.4", features = ["derive", "cargo"] }
|
||||||
|
ratatui = "0.26"
|
||||||
|
tree-sitter = "0.22"
|
||||||
|
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||||
|
regex = "1.10"
|
||||||
|
anyhow = "1.0"
|
||||||
|
toml = "0.8"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
ignore = "0.4"
|
||||||
|
crossterm = "0.28"
|
||||||
|
ansi-to-tui = "4"
|
||||||
|
unicode-width = "0.1"
|
||||||
|
dirs = "5"
|
||||||
|
chrono = { version = "0.4", features = ["std"] }
|
||||||
|
glob = "0.3"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
predicates = "3"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
dev = ["ratatui/crossterm"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
|
||||||
|
[workspace]
|
||||||
21
techdebt-tracker-cli/LICENSE
Normal file
21
techdebt-tracker-cli/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 TechDebt Tracker Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
228
techdebt-tracker-cli/README.md
Normal file
228
techdebt-tracker-cli/README.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# TechDebt Tracker CLI
|
||||||
|
|
||||||
|
A Rust-based CLI tool that analyzes codebases to extract, categorize, and visualize TODO/FIXME/HACK comments using tree-sitter for multi-language parsing, providing an interactive TUI dashboard and export capabilities.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multi-language Support**: Parse TODO/FIXME/HACK comments in JavaScript, TypeScript, Python, Rust, Go, Java, C/C++, Ruby, and more
|
||||||
|
- **Priority Categorization**: Automatically categorize technical debt by priority (Critical, High, Medium, Low) based on keywords and context
|
||||||
|
- **Interactive TUI Dashboard**: Visualize technical debt with an interactive terminal UI
|
||||||
|
- **Export Capabilities**: Export reports to JSON and Markdown formats
|
||||||
|
- **Complexity Estimation**: Estimate complexity of technical debt items based on comment content and context
|
||||||
|
- **Configurable Patterns**: Define custom comment patterns via YAML configuration
|
||||||
|
- **Ignore Patterns**: Exclude directories and files using .gitignore-style patterns
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/example/techdebt-tracker-cli.git
|
||||||
|
cd techdebt-tracker-cli
|
||||||
|
cargo build --release
|
||||||
|
cargo install --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Cargo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install techdebt-tracker-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Initialize Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
techdebt-tracker init
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analyze a Directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Analyze current directory
|
||||||
|
techdebt-tracker analyze
|
||||||
|
|
||||||
|
# Analyze specific directory
|
||||||
|
techdebt-tracker analyze --path /path/to/project
|
||||||
|
|
||||||
|
# Output to file
|
||||||
|
techdebt-tracker analyze --output report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Open Interactive Dashboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
techdebt-tracker tui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export Report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Export to JSON
|
||||||
|
techdebt-tracker export --output report.json --format json
|
||||||
|
|
||||||
|
# Export to Markdown
|
||||||
|
techdebt-tracker export --output report.md --format markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a `techdebt.yaml` file in your project root or in `~/.config/techdebt-tracker/`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
patterns:
|
||||||
|
- keyword: "FIXME"
|
||||||
|
priority: critical
|
||||||
|
regex: false
|
||||||
|
- keyword: "TODO"
|
||||||
|
priority: medium
|
||||||
|
regex: false
|
||||||
|
- keyword: "HACK"
|
||||||
|
priority: low
|
||||||
|
regex: false
|
||||||
|
|
||||||
|
languages:
|
||||||
|
- javascript
|
||||||
|
- typescript
|
||||||
|
- python
|
||||||
|
- rust
|
||||||
|
|
||||||
|
ignore:
|
||||||
|
- "node_modules/**"
|
||||||
|
- "target/**"
|
||||||
|
- ".git/**"
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
### analyze
|
||||||
|
|
||||||
|
Analyze codebase and show summary of technical debt.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
techdebt-tracker analyze [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-p, --path <PATH> Directory to analyze (default: current directory)
|
||||||
|
-o, --output <FILE> Output file for results
|
||||||
|
-v, --verbose Show verbose output
|
||||||
|
```
|
||||||
|
|
||||||
|
### tui
|
||||||
|
|
||||||
|
Open interactive TUI dashboard.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
techdebt-tracker tui [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-p, --path <PATH> Directory to analyze (default: current directory)
|
||||||
|
```
|
||||||
|
|
||||||
|
### export
|
||||||
|
|
||||||
|
Export analysis to file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
techdebt-tracker export [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-p, --path <PATH> Directory to analyze (default: current directory)
|
||||||
|
-o, --output <FILE> Output file (required)
|
||||||
|
-f, --format <FORMAT> Export format: json or markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
### init
|
||||||
|
|
||||||
|
Initialize default configuration file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
techdebt-tracker init [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-p, --path <PATH> Directory to create config in (default: current directory)
|
||||||
|
```
|
||||||
|
|
||||||
|
## TUI Navigation
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `Tab` | Switch between Dashboard and List views |
|
||||||
|
| `↑` / `↓` | Navigate items |
|
||||||
|
| `Enter` | View item details |
|
||||||
|
| `/` or `f` | Filter items |
|
||||||
|
| `1-4` | Filter by priority (1=Critical, 2=High, 3=Medium, 4=Low) |
|
||||||
|
| `s` | Cycle sort order |
|
||||||
|
| `c` | Clear filters |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
## Export Formats
|
||||||
|
|
||||||
|
### JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": {
|
||||||
|
"total_items": 42,
|
||||||
|
"by_priority": {
|
||||||
|
"critical": 5,
|
||||||
|
"high": 10,
|
||||||
|
"medium": 20,
|
||||||
|
"low": 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"items": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Markdown
|
||||||
|
|
||||||
|
Generates a formatted report with:
|
||||||
|
- Summary statistics
|
||||||
|
- Priority breakdown with visual bars
|
||||||
|
- Language distribution
|
||||||
|
- Detailed item list grouped by priority
|
||||||
|
|
||||||
|
## Supported Languages
|
||||||
|
|
||||||
|
- JavaScript (.js, .jsx)
|
||||||
|
- TypeScript (.ts, .tsx)
|
||||||
|
- Python (.py)
|
||||||
|
- Rust (.rs)
|
||||||
|
- Go (.go)
|
||||||
|
- Java (.java)
|
||||||
|
- C (.c)
|
||||||
|
- C++ (.cpp, .cc, .cxx, .h, .hpp)
|
||||||
|
- Ruby (.rb)
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug build
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Release build
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cargo test --all-features
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
cargo clippy --all-targets
|
||||||
|
|
||||||
|
# Check formatting
|
||||||
|
cargo fmt --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Run tests and linting
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details.
|
||||||
3
techdebt-tracker-cli/rust-toolchain.toml
Normal file
3
techdebt-tracker-cli/rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
163
techdebt-tracker-cli/src/cli/mod.rs
Normal file
163
techdebt-tracker-cli/src/cli/mod.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "techdebt-tracker")]
|
||||||
|
#[command(author = "TechDebt Tracker Contributors")]
|
||||||
|
#[command(version = "0.1.0")]
|
||||||
|
#[command(about = "Track and analyze technical debt in your codebase", long_about = None)]
|
||||||
|
pub struct Args {
|
||||||
|
#[arg(short, long, global = true)]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum Commands {
|
||||||
|
#[command(about = "Analyze codebase and show summary")]
|
||||||
|
Analyze(AnalyzeArgs),
|
||||||
|
#[command(about = "Open interactive TUI dashboard")]
|
||||||
|
Tui(TuiArgs),
|
||||||
|
#[command(about = "Export analysis to file")]
|
||||||
|
Export(ExportArgs),
|
||||||
|
#[command(about = "Initialize default configuration")]
|
||||||
|
Init(InitArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args, Debug)]
|
||||||
|
pub struct AnalyzeArgs {
|
||||||
|
#[arg(short, long, default_value = ".")]
|
||||||
|
pub path: PathBuf,
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub output: Option<PathBuf>,
|
||||||
|
#[arg(short, long, action)]
|
||||||
|
pub verbose: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args, Debug)]
|
||||||
|
pub struct TuiArgs {
|
||||||
|
#[arg(short, long, default_value = ".")]
|
||||||
|
pub path: PathBuf,
|
||||||
|
#[arg(short, long, action)]
|
||||||
|
pub verbose: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args, Debug)]
|
||||||
|
pub struct ExportArgs {
|
||||||
|
#[arg(short, long, default_value = ".")]
|
||||||
|
pub path: PathBuf,
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub output: PathBuf,
|
||||||
|
#[arg(short, long, value_enum)]
|
||||||
|
pub format: ExportFormat,
|
||||||
|
#[arg(long)]
|
||||||
|
pub group_by: Option<GroupBy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||||
|
pub enum ExportFormat {
|
||||||
|
Json,
|
||||||
|
Markdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||||
|
pub enum GroupBy {
|
||||||
|
File,
|
||||||
|
Priority,
|
||||||
|
Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args, Debug)]
|
||||||
|
pub struct InitArgs {
|
||||||
|
#[arg(short, long, default_value = ".")]
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_config(path: &PathBuf) -> anyhow::Result<()> {
|
||||||
|
let config_content = r#"# TechDebt Tracker Configuration
|
||||||
|
# https://github.com/example/techdebt-tracker-cli
|
||||||
|
|
||||||
|
# Comment patterns to search for
|
||||||
|
patterns:
|
||||||
|
- keyword: "FIXME"
|
||||||
|
priority: critical
|
||||||
|
regex: false
|
||||||
|
- keyword: "TODO"
|
||||||
|
priority: medium
|
||||||
|
regex: false
|
||||||
|
- keyword: "HACK"
|
||||||
|
priority: low
|
||||||
|
regex: false
|
||||||
|
- keyword: "BUG"
|
||||||
|
priority: high
|
||||||
|
regex: false
|
||||||
|
- keyword: "XXX"
|
||||||
|
priority: high
|
||||||
|
regex: false
|
||||||
|
- keyword: "NOTE"
|
||||||
|
priority: low
|
||||||
|
regex: false
|
||||||
|
|
||||||
|
# Languages to analyze
|
||||||
|
languages:
|
||||||
|
- javascript
|
||||||
|
- typescript
|
||||||
|
- python
|
||||||
|
- rust
|
||||||
|
- go
|
||||||
|
- java
|
||||||
|
- c
|
||||||
|
- cpp
|
||||||
|
- ruby
|
||||||
|
|
||||||
|
# Directories and files to ignore
|
||||||
|
ignore:
|
||||||
|
- "node_modules/**"
|
||||||
|
- "target/**"
|
||||||
|
- ".git/**"
|
||||||
|
- "vendor/**"
|
||||||
|
- "dist/**"
|
||||||
|
- "build/**"
|
||||||
|
- "*.min.js"
|
||||||
|
- "*.min.css"
|
||||||
|
- "*.pyc"
|
||||||
|
- "__pycache__/**"
|
||||||
|
|
||||||
|
# File extensions to include
|
||||||
|
extensions:
|
||||||
|
- ".js"
|
||||||
|
- ".ts"
|
||||||
|
- ".jsx"
|
||||||
|
- ".tsx"
|
||||||
|
- ".py"
|
||||||
|
- ".rs"
|
||||||
|
- ".go"
|
||||||
|
- ".java"
|
||||||
|
- ".c"
|
||||||
|
- ".cpp"
|
||||||
|
- ".h"
|
||||||
|
- ".hpp"
|
||||||
|
- ".rb"
|
||||||
|
- ".md"
|
||||||
|
- ".yml"
|
||||||
|
- ".yaml"
|
||||||
|
|
||||||
|
# Complexity analysis settings
|
||||||
|
complexity:
|
||||||
|
enabled: true
|
||||||
|
max_comment_length: 500
|
||||||
|
question_weight: 2
|
||||||
|
exclamation_weight: 1
|
||||||
|
|
||||||
|
# Export settings
|
||||||
|
export:
|
||||||
|
include_metadata: true
|
||||||
|
include_context: true
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config_path = path.join("techdebt.yaml");
|
||||||
|
std::fs::write(&config_path, config_content)?;
|
||||||
|
println!("Created configuration file: {}", config_path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
148
techdebt-tracker-cli/src/core/analyzer.rs
Normal file
148
techdebt-tracker-cli/src/core/analyzer.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use ignore::WalkBuilder;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
AnalysisSummary, ByLanguage, ByPriority, CommentType, Config, FileLocation, Priority,
|
||||||
|
TechDebtItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Analyzer {
|
||||||
|
path: PathBuf,
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Analyzer {
|
||||||
|
pub fn new(path: &PathBuf, config_path: &Option<PathBuf>) -> Result<Self> {
|
||||||
|
let config = load_config(config_path)?;
|
||||||
|
Ok(Self {
|
||||||
|
path: path.clone(),
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn analyze(&self) -> Result<Vec<TechDebtItem>> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
let walker = WalkBuilder::new(&self.path)
|
||||||
|
.hidden(true)
|
||||||
|
.git_global(true)
|
||||||
|
.git_ignore(true)
|
||||||
|
.require_git(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for result in walker {
|
||||||
|
match result {
|
||||||
|
Ok(entry) => {
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if !self.should_include(path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(language) = Language::from_path(path) {
|
||||||
|
match self.parse_file(path, &language) {
|
||||||
|
Ok(mut file_items) => items.append(&mut file_items),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: Failed to parse {}: {}", path.display(), e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Warning: Error walking directory: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
b.priority
|
||||||
|
.cmp(&a.priority)
|
||||||
|
.then_with(|| a.location.line.cmp(&b.location.line))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_include(&self, path: &PathBuf) -> bool {
|
||||||
|
if !path.is_file() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ext) = path.extension() {
|
||||||
|
if let Some(ext_str) = ext.to_str() {
|
||||||
|
let ext_with_dot = format!(".{}", ext_str);
|
||||||
|
if !self.config.extensions.contains(&ext_with_dot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pattern in &self.config.ignore {
|
||||||
|
if match_ignore_pattern(path, pattern) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_file(
|
||||||
|
&self,
|
||||||
|
path: &PathBuf,
|
||||||
|
language: &Language,
|
||||||
|
) -> Result<Vec<TechDebtItem>> {
|
||||||
|
let content = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("Failed to read file: {}", path.display()))?;
|
||||||
|
|
||||||
|
let parser = LanguageParser::new(language.clone());
|
||||||
|
parser.parse(&content, path, &self.config.patterns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match_ignore_pattern(path: &PathBuf, pattern: &str) -> bool {
|
||||||
|
if pattern.ends_with("/**") {
|
||||||
|
let prefix = &pattern[..pattern.len() - 3];
|
||||||
|
if let Some(path_str) = path.to_str() {
|
||||||
|
return path_str.starts_with(prefix)
|
||||||
|
|| path_str.contains(&format!("{}/", prefix));
|
||||||
|
}
|
||||||
|
} else if let Some(file_name) = path.file_name() {
|
||||||
|
if let Some(file_name_str) = file_name.to_str() {
|
||||||
|
return glob::Pattern::new(pattern)
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.matches(file_name_str))
|
||||||
|
.unwrap_or(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config(config_path: &Option<PathBuf>) -> Result<Config> {
|
||||||
|
let config_path = if let Some(path) = config_path {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()?
|
||||||
|
.join("techdebt.yaml")
|
||||||
|
};
|
||||||
|
|
||||||
|
if config_path.exists() {
|
||||||
|
let content = std::fs::read_to_string(&config_path)?;
|
||||||
|
let config: Config = serde_yaml::from_str(&content)?;
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Config::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summarize(items: &[TechDebtItem]) -> AnalysisSummary {
|
||||||
|
let by_priority = ByPriority::from_items(items);
|
||||||
|
let by_language = ByLanguage::from_items(items);
|
||||||
|
let complexity_distribution =
|
||||||
|
crate::models::ComplexityDistribution::from_items(items);
|
||||||
|
|
||||||
|
AnalysisSummary {
|
||||||
|
total_items: items.len(),
|
||||||
|
by_priority,
|
||||||
|
by_language,
|
||||||
|
complexity_distribution,
|
||||||
|
}
|
||||||
|
}
|
||||||
370
techdebt-tracker-cli/src/core/language.rs
Normal file
370
techdebt-tracker-cli/src/core/language.rs
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::models::{CommentType, FileLocation, PatternConfig, Priority, TechDebtItem};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Language {
|
||||||
|
JavaScript,
|
||||||
|
TypeScript,
|
||||||
|
Python,
|
||||||
|
Rust,
|
||||||
|
Go,
|
||||||
|
Java,
|
||||||
|
C,
|
||||||
|
Cpp,
|
||||||
|
Ruby,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Language {
|
||||||
|
pub fn from_path(path: &PathBuf) -> Option<Self> {
|
||||||
|
path.extension().and_then(|ext| {
|
||||||
|
let ext_str = ext.to_str()?.to_lowercase();
|
||||||
|
match ext_str.as_str() {
|
||||||
|
"js" => Some(Language::JavaScript),
|
||||||
|
"ts" => Some(Language::TypeScript),
|
||||||
|
"jsx" => Some(Language::JavaScript),
|
||||||
|
"tsx" => Some(Language::TypeScript),
|
||||||
|
"py" => Some(Language::Python),
|
||||||
|
"rs" => Some(Language::Rust),
|
||||||
|
"go" => Some(Language::Go),
|
||||||
|
"java" => Some(Language::Java),
|
||||||
|
"c" => Some(Language::C),
|
||||||
|
"cpp" | "cc" | "cxx" => Some(Language::Cpp),
|
||||||
|
"h" | "hpp" => Some(Language::Cpp),
|
||||||
|
"rb" => Some(Language::Ruby),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Language::JavaScript => "JavaScript",
|
||||||
|
Language::TypeScript => "TypeScript",
|
||||||
|
Language::Python => "Python",
|
||||||
|
Language::Rust => "Rust",
|
||||||
|
Language::Go => "Go",
|
||||||
|
Language::Java => "Java",
|
||||||
|
Language::C => "C",
|
||||||
|
Language::Cpp => "C++",
|
||||||
|
Language::Ruby => "Ruby",
|
||||||
|
Language::Unknown => "Unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn single_line_comment(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Language::JavaScript | Language::TypeScript | Language::Java | Language::C
|
||||||
|
| Language::Cpp | Language::Rust | Language::Go | Language::Ruby => Some("//"),
|
||||||
|
Language::Python => Some("#"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn multi_line_comment_start(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Language::JavaScript | Language::TypeScript | Language::Java | Language::C
|
||||||
|
| Language::Cpp | Language::Ruby => Some("/*"),
|
||||||
|
Language::Python => Some(""),
|
||||||
|
Language::Rust => Some("/*"),
|
||||||
|
Language::Go => Some("/*"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn multi_line_comment_end(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Language::JavaScript | Language::TypeScript | Language::Java | Language::C
|
||||||
|
| Language::Cpp | Language::Ruby => Some("*/"),
|
||||||
|
Language::Python => Some(""),
|
||||||
|
Language::Rust => Some("*/"),
|
||||||
|
Language::Go => Some("*/"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn doc_comment_start(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Language::JavaScript | Language::TypeScript => Some("/**"),
|
||||||
|
Language::Java => Some("/**"),
|
||||||
|
Language::Rust => Some("///"),
|
||||||
|
Language::Python => Some("##"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LanguageParser {
|
||||||
|
language: Language,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LanguageParser {
|
||||||
|
pub fn new(language: Language) -> Self {
|
||||||
|
Self { language }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(
|
||||||
|
&self,
|
||||||
|
content: &str,
|
||||||
|
path: &PathBuf,
|
||||||
|
patterns: &[PatternConfig],
|
||||||
|
) -> Result<Vec<TechDebtItem>, anyhow::Error> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
|
||||||
|
let single_line_comment = self.language.single_line_comment();
|
||||||
|
let multi_line_start = self.language.multi_line_comment_start();
|
||||||
|
let multi_line_end = self.language.multi_line_comment_end();
|
||||||
|
|
||||||
|
let mut in_multi_line = false;
|
||||||
|
let mut multi_line_start_line = 0;
|
||||||
|
let mut multi_line_content = String::new();
|
||||||
|
let mut multi_line_start_col = 0;
|
||||||
|
|
||||||
|
let single_patterns: Vec<&PatternConfig> =
|
||||||
|
patterns.iter().filter(|p| !p.regex).collect();
|
||||||
|
let regex_patterns: Vec<(regex::Regex, &PatternConfig)> = patterns
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.regex)
|
||||||
|
.filter_map(|p| {
|
||||||
|
regex::Regex::new(&p.keyword)
|
||||||
|
.ok()
|
||||||
|
.map(|re| (re, p))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (line_num, line) in lines.iter().enumerate() {
|
||||||
|
let line_num = line_num + 1;
|
||||||
|
|
||||||
|
if let Some(slc) = single_line_comment {
|
||||||
|
if let Some(comment_start) = line.find(slc) {
|
||||||
|
let comment_text = &line[comment_start + slc.len()..];
|
||||||
|
let col_start = comment_start + slc.len() + 1;
|
||||||
|
|
||||||
|
for pattern in &single_patterns {
|
||||||
|
if let Some(pos) = comment_text.find(&pattern.keyword) {
|
||||||
|
let item_content = &comment_text[pos..];
|
||||||
|
let content_clean = item_content
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or(item_content)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if self.matches_pattern(content_clean, &single_patterns)
|
||||||
|
|| self.matches_regex(content_clean, ®ex_patterns)
|
||||||
|
{
|
||||||
|
let item = TechDebtItem::new(
|
||||||
|
pattern.keyword.clone(),
|
||||||
|
content_clean.to_string(),
|
||||||
|
FileLocation {
|
||||||
|
path: path.clone(),
|
||||||
|
line: line_num,
|
||||||
|
column: col_start + pos,
|
||||||
|
end_line: None,
|
||||||
|
end_column: None,
|
||||||
|
},
|
||||||
|
self.language.as_str().to_string(),
|
||||||
|
CommentType::SingleLine,
|
||||||
|
);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (regex, pattern) in ®ex_patterns {
|
||||||
|
if let Some(mat) = regex.find(comment_text) {
|
||||||
|
let item = TechDebtItem::new(
|
||||||
|
pattern.keyword.clone(),
|
||||||
|
mat.as_str().to_string(),
|
||||||
|
FileLocation {
|
||||||
|
path: path.clone(),
|
||||||
|
line: line_num,
|
||||||
|
column: col_start + mat.start(),
|
||||||
|
end_line: None,
|
||||||
|
end_column: None,
|
||||||
|
},
|
||||||
|
self.language.as_str().to_string(),
|
||||||
|
CommentType::SingleLine,
|
||||||
|
);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mls) = multi_line_start {
|
||||||
|
if !in_multi_line {
|
||||||
|
if let Some(start_pos) = line.find(mls) {
|
||||||
|
in_multi_line = true;
|
||||||
|
multi_line_start_line = line_num;
|
||||||
|
multi_line_start_col = start_pos + mls.len();
|
||||||
|
if let Some(end_pos) = line.find(multi_line_end.unwrap_or("")) {
|
||||||
|
let comment_content = &line[start_pos + mls.len()..end_pos];
|
||||||
|
if let Some(content) = self.extract_comment_content(
|
||||||
|
comment_content,
|
||||||
|
&lines,
|
||||||
|
line_num,
|
||||||
|
start_pos + mls.len() + 1,
|
||||||
|
&single_patterns,
|
||||||
|
®ex_patterns,
|
||||||
|
path,
|
||||||
|
) {
|
||||||
|
items.extend(content);
|
||||||
|
}
|
||||||
|
in_multi_line = false;
|
||||||
|
} else {
|
||||||
|
multi_line_content = line
|
||||||
|
[start_pos + mls.len()..]
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(end_pos) = line.find(multi_line_end.unwrap_or("")) {
|
||||||
|
multi_line_content.push('\n');
|
||||||
|
multi_line_content.push_str(&line[..end_pos]);
|
||||||
|
if let Some(content) = self.extract_comment_content(
|
||||||
|
&multi_line_content,
|
||||||
|
&lines,
|
||||||
|
multi_line_start_line,
|
||||||
|
multi_line_start_col,
|
||||||
|
&single_patterns,
|
||||||
|
®ex_patterns,
|
||||||
|
path,
|
||||||
|
) {
|
||||||
|
items.extend(content);
|
||||||
|
}
|
||||||
|
in_multi_line = false;
|
||||||
|
multi_line_content.clear();
|
||||||
|
} else {
|
||||||
|
multi_line_content.push('\n');
|
||||||
|
multi_line_content.push_str(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(dls) = self.language.doc_comment_start() {
|
||||||
|
if let Some(doc_start) = line.find(dls) {
|
||||||
|
let is_block_comment = dls == "/**";
|
||||||
|
let comment_text = if is_block_comment {
|
||||||
|
if let Some(end_pos) = line.find("*/") {
|
||||||
|
&line[doc_start + 3..end_pos]
|
||||||
|
} else {
|
||||||
|
&line[doc_start + 3..]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
&line[doc_start + 3..]
|
||||||
|
};
|
||||||
|
|
||||||
|
for pattern in &single_patterns {
|
||||||
|
if let Some(pos) = comment_text.find(&pattern.keyword) {
|
||||||
|
let item_content = &comment_text[pos..];
|
||||||
|
let content_clean = item_content
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or(item_content)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if self.matches_pattern(content_clean, &single_patterns)
|
||||||
|
|| self.matches_regex(content_clean, ®ex_patterns)
|
||||||
|
{
|
||||||
|
let item = TechDebtItem::new(
|
||||||
|
pattern.keyword.clone(),
|
||||||
|
content_clean.to_string(),
|
||||||
|
FileLocation {
|
||||||
|
path: path.clone(),
|
||||||
|
line: line_num,
|
||||||
|
column: doc_start + 3 + pos,
|
||||||
|
end_line: None,
|
||||||
|
end_column: None,
|
||||||
|
},
|
||||||
|
self.language.as_str().to_string(),
|
||||||
|
CommentType::DocBlock,
|
||||||
|
);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_pattern(&self, content: &str, patterns: &[&PatternConfig]) -> bool {
|
||||||
|
patterns.iter().any(|p| content.contains(&p.keyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_regex(
|
||||||
|
&self,
|
||||||
|
content: &str,
|
||||||
|
regex_patterns: &[(regex::Regex, &PatternConfig)],
|
||||||
|
) -> bool {
|
||||||
|
regex_patterns.iter().any(|(re, _)| re.is_match(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_comment_content(
|
||||||
|
&self,
|
||||||
|
content: &str,
|
||||||
|
lines: &[&str],
|
||||||
|
start_line: usize,
|
||||||
|
start_col: usize,
|
||||||
|
patterns: &[&PatternConfig],
|
||||||
|
regex_patterns: &[(regex::Regex, &PatternConfig)],
|
||||||
|
path: &PathBuf,
|
||||||
|
) -> Option<Vec<TechDebtItem>> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
for pattern in patterns {
|
||||||
|
let regex = regex::Regex::new(&format!(r"(?i){}", pattern.keyword)).unwrap();
|
||||||
|
for mat in regex.find_iter(content) {
|
||||||
|
let line_in_content = content[..mat.start()].lines().count() + start_line;
|
||||||
|
let col_in_content = content[..mat.start()].lines().last().map_or(0, |l| l.len());
|
||||||
|
|
||||||
|
let item = TechDebtItem::new(
|
||||||
|
pattern.keyword.clone(),
|
||||||
|
mat.as_str().to_string(),
|
||||||
|
FileLocation {
|
||||||
|
path: path.clone(),
|
||||||
|
line: line_in_content,
|
||||||
|
column: start_col + col_in_content,
|
||||||
|
end_line: None,
|
||||||
|
end_column: None,
|
||||||
|
},
|
||||||
|
self.language.as_str().to_string(),
|
||||||
|
CommentType::MultiLine,
|
||||||
|
);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (regex, pattern) in regex_patterns {
|
||||||
|
for mat in regex.find_iter(content) {
|
||||||
|
let line_in_content = content[..mat.start()].lines().count() + start_line;
|
||||||
|
let col_in_content = content[..mat.start()].lines().last().map_or(0, |l| l.len());
|
||||||
|
|
||||||
|
let item = TechDebtItem::new(
|
||||||
|
pattern.keyword.clone(),
|
||||||
|
mat.as_str().to_string(),
|
||||||
|
FileLocation {
|
||||||
|
path: path.clone(),
|
||||||
|
line: line_in_content,
|
||||||
|
column: start_col + col_in_content,
|
||||||
|
end_line: None,
|
||||||
|
end_column: None,
|
||||||
|
},
|
||||||
|
self.language.as_str().to_string(),
|
||||||
|
CommentType::MultiLine,
|
||||||
|
);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
techdebt-tracker-cli/src/core/mod.rs
Normal file
11
techdebt-tracker-cli/src/core/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use crate::models::{
|
||||||
|
AnalysisSummary, ByLanguage, ByPriority, CommentType, Config, FileLocation, Priority,
|
||||||
|
TechDebtItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
mod language;
|
||||||
|
pub use language::{Language, LanguageParser};
|
||||||
|
|
||||||
|
pub mod analyzer;
|
||||||
|
|
||||||
|
pub use analyzer::{summarize, Analyzer};
|
||||||
181
techdebt-tracker-cli/src/export/exporter.rs
Normal file
181
techdebt-tracker-cli/src/export/exporter.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
AnalysisSummary, ByLanguage, ByPriority, ComplexityDistribution, TechDebtItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Exporter;
|
||||||
|
|
||||||
|
impl Exporter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_json(&self, items: &[TechDebtItem], output: &PathBuf) -> anyhow::Result<()> {
|
||||||
|
let summary = self.create_summary(items);
|
||||||
|
|
||||||
|
let export_data = ExportData {
|
||||||
|
summary,
|
||||||
|
items: items.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&export_data)?;
|
||||||
|
let mut file = File::create(output)?;
|
||||||
|
file.write_all(json.as_bytes())?;
|
||||||
|
|
||||||
|
println!("Exported {} items to {}", items.len(), output.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_markdown(&self, items: &[TechDebtItem], output: &PathBuf) -> anyhow::Result<()> {
|
||||||
|
let mut content = String::new();
|
||||||
|
|
||||||
|
content.push_str("# Technical Debt Report\n\n");
|
||||||
|
content.push_str(&format!(
|
||||||
|
"**Generated:** {}\n\n",
|
||||||
|
chrono::Local::now().to_rfc2829()
|
||||||
|
));
|
||||||
|
|
||||||
|
let summary = self.create_summary(items);
|
||||||
|
content.push_str("## Summary\n\n");
|
||||||
|
content.push_str("| Metric | Count |\n|--------|-------|\n");
|
||||||
|
content.push_str(&format!("| Total Items | {} |\n", summary.total_items));
|
||||||
|
content.push_str(&format!(
|
||||||
|
"| Critical | {} |\n",
|
||||||
|
summary.by_priority.critical
|
||||||
|
));
|
||||||
|
content.push_str(&format!("| High | {} |\n", summary.by_priority.high));
|
||||||
|
content.push_str(&format!("| Medium | {} |\n", summary.by_priority.medium));
|
||||||
|
content.push_str(&format!("| Low | {} |\n", summary.by_priority.low));
|
||||||
|
content.push_str("\n");
|
||||||
|
|
||||||
|
content.push_str("## By Priority\n\n");
|
||||||
|
content.push_str("| Priority | Count | Bar |\n|---------|-------|-----|\n");
|
||||||
|
for (priority_str, count) in [
|
||||||
|
("Critical", summary.by_priority.critical),
|
||||||
|
("High", summary.by_priority.high),
|
||||||
|
("Medium", summary.by_priority.medium),
|
||||||
|
("Low", summary.by_priority.low),
|
||||||
|
] {
|
||||||
|
let bar = "█".repeat(count.min(50));
|
||||||
|
content.push_str(&format!("| {} | {} | {} |\n", priority_str, count, bar));
|
||||||
|
}
|
||||||
|
content.push_str("\n");
|
||||||
|
|
||||||
|
content.push_str("## By Language\n\n");
|
||||||
|
content.push_str("| Language | Count |\n|----------|-------|\n");
|
||||||
|
for lang in &summary.by_language.items {
|
||||||
|
content.push_str(&format!("| {} | {} |\n", lang.language, lang.count));
|
||||||
|
}
|
||||||
|
content.push_str("\n");
|
||||||
|
|
||||||
|
content.push_str("## Technical Debt Items\n\n");
|
||||||
|
|
||||||
|
let priority_order = ["Critical", "High", "Medium", "Low"];
|
||||||
|
for priority_str in priority_order {
|
||||||
|
let priority_items: Vec<_> = items
|
||||||
|
.iter()
|
||||||
|
.filter(|i| i.priority.as_str() == priority_str)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !priority_items.is_empty() {
|
||||||
|
content.push_str(&format!("### {}\n\n", priority_str));
|
||||||
|
|
||||||
|
let mut sorted_items: Vec<_> = priority_items.iter().collect();
|
||||||
|
sorted_items.sort_by_key(|i| &i.location.path);
|
||||||
|
|
||||||
|
for item in sorted_items {
|
||||||
|
content.push_str(&format!(
|
||||||
|
"- **{}** at `{}:{}`\n",
|
||||||
|
item.keyword,
|
||||||
|
item.location.path.display(),
|
||||||
|
item.location.line
|
||||||
|
));
|
||||||
|
content.push_str(&format!(" - {}\n", self.truncate(&item.content, 100)));
|
||||||
|
content.push_str(&format!(" - Complexity: {}/10\n", item.complexity_score));
|
||||||
|
content.push_str(&format!(" - Language: {}\n", item.metadata.language));
|
||||||
|
content.push_str("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = File::create(output)?;
|
||||||
|
file.write_all(content.as_bytes())?;
|
||||||
|
|
||||||
|
println!("Exported to {}", output.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_summary(&self, items: &[TechDebtItem]) {
|
||||||
|
let summary = self.create_summary(items);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("═══════════════════════════════════════════");
|
||||||
|
println!(" TECHNICAL DEBT ANALYSIS ");
|
||||||
|
println!("═══════════════════════════════════════════");
|
||||||
|
println!();
|
||||||
|
println!(" Total Items: {}", summary.total_items);
|
||||||
|
println!();
|
||||||
|
println!(" Priority Breakdown:");
|
||||||
|
println!(" 🔴 Critical: {}", summary.by_priority.critical);
|
||||||
|
println!(" 🟠 High: {}", summary.by_priority.high);
|
||||||
|
println!(" 🟡 Medium: {}", summary.by_priority.medium);
|
||||||
|
println!(" 🟢 Low: {}", summary.by_priority.low);
|
||||||
|
println!();
|
||||||
|
println!(" Complexity Distribution:");
|
||||||
|
println!(" Low (1-3): {}", summary.complexity_distribution.low);
|
||||||
|
println!(
|
||||||
|
" Medium (4-6): {}",
|
||||||
|
summary.complexity_distribution.medium
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" High (7-8): {}",
|
||||||
|
summary.complexity_distribution.high
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Critical (9+): {}",
|
||||||
|
summary.complexity_distribution.critical
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!(" By Language:");
|
||||||
|
for lang in summary.by_language.items.iter().take(5) {
|
||||||
|
println!(" {}: {}", lang.language, lang.count);
|
||||||
|
}
|
||||||
|
if summary.by_language.items.len() > 5 {
|
||||||
|
println!(" ... and {} more", summary.by_language.items.len() - 5);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!("═══════════════════════════════════════════");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_summary(&self, items: &[TechDebtItem]) -> AnalysisSummary {
|
||||||
|
let by_priority = ByPriority::from_items(items);
|
||||||
|
let by_language = ByLanguage::from_items(items);
|
||||||
|
let complexity_distribution = ComplexityDistribution::from_items(items);
|
||||||
|
|
||||||
|
AnalysisSummary {
|
||||||
|
total_items: items.len(),
|
||||||
|
by_priority,
|
||||||
|
by_language,
|
||||||
|
complexity_distribution,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(&self, s: &str, max_len: usize) -> String {
|
||||||
|
if s.len() <= max_len {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
let mut truncated = s[..max_len - 3].to_string();
|
||||||
|
truncated.push_str("...");
|
||||||
|
truncated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct ExportData {
|
||||||
|
summary: AnalysisSummary,
|
||||||
|
items: Vec<TechDebtItem>,
|
||||||
|
}
|
||||||
6
techdebt-tracker-cli/src/export/mod.rs
Normal file
6
techdebt-tracker-cli/src/export/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::models::{AnalysisSummary, TechDebtItem};
|
||||||
|
|
||||||
|
pub mod exporter;
|
||||||
|
pub use exporter::Exporter;
|
||||||
64
techdebt-tracker-cli/src/main.rs
Normal file
64
techdebt-tracker-cli/src/main.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
mod cli;
|
||||||
|
mod core;
|
||||||
|
mod export;
|
||||||
|
mod models;
|
||||||
|
mod tui;
|
||||||
|
|
||||||
|
use cli::Args;
|
||||||
|
use core::analyzer::Analyzer;
|
||||||
|
use export::exporter::Exporter;
|
||||||
|
use tui::app::TuiApp;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if let Err(e) = run() {
|
||||||
|
eprintln!("Error: {e}");
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
match &args.command {
|
||||||
|
cli::Commands::Analyze(analyze_args) => {
|
||||||
|
let analyzer = Analyzer::new(&analyze_args.path, &args.config)?;
|
||||||
|
let items = analyzer.analyze()?;
|
||||||
|
let exporter = Exporter::new();
|
||||||
|
|
||||||
|
if let Some(output) = &analyze_args.output {
|
||||||
|
exporter.export_json(&items, output)?;
|
||||||
|
} else {
|
||||||
|
exporter.print_summary(&items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli::Commands::Tui(tui_args) => {
|
||||||
|
let analyzer = Analyzer::new(&tui_args.path, &args.config)?;
|
||||||
|
let items = analyzer.analyze()?;
|
||||||
|
let mut app = TuiApp::new(items, tui_args.path.clone());
|
||||||
|
app.run()?;
|
||||||
|
}
|
||||||
|
cli::Commands::Export(export_args) => {
|
||||||
|
let analyzer = Analyzer::new(&export_args.path, &args.config)?;
|
||||||
|
let items = analyzer.analyze()?;
|
||||||
|
let exporter = Exporter::new();
|
||||||
|
|
||||||
|
match export_args.format {
|
||||||
|
cli::ExportFormat::Json => {
|
||||||
|
exporter.export_json(&items, &export_args.output)?;
|
||||||
|
}
|
||||||
|
cli::ExportFormat::Markdown => {
|
||||||
|
exporter.export_markdown(&items, &export_args.output)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli::Commands::Init(init_args) => {
|
||||||
|
cli::init_config(&init_args.path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
400
techdebt-tracker-cli/src/models/mod.rs
Normal file
400
techdebt-tracker-cli/src/models/mod.rs
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct FileLocation {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub line: usize,
|
||||||
|
pub column: usize,
|
||||||
|
pub end_line: Option<usize>,
|
||||||
|
pub end_column: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Priority {
|
||||||
|
Critical,
|
||||||
|
High,
|
||||||
|
Medium,
|
||||||
|
Low,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Priority {
|
||||||
|
pub fn from_keyword(keyword: &str) -> Self {
|
||||||
|
match keyword.to_uppercase().as_str() {
|
||||||
|
"FIXME" | "BUG" | "ERROR" => Priority::Critical,
|
||||||
|
"XXX" | "URGENT" | "CRITICAL" => Priority::High,
|
||||||
|
"TODO" | "TEMP" | "PERF" => Priority::Medium,
|
||||||
|
"HACK" | "NOTE" | "REFACTOR" | "XXX" => Priority::Low,
|
||||||
|
_ => Priority::Medium,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Priority::Critical => "Critical",
|
||||||
|
Priority::High => "High",
|
||||||
|
Priority::Medium => "Medium",
|
||||||
|
Priority::Low => "Low",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for Priority {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for Priority {
|
||||||
|
fn cmp(&self, other: &Self) -> Ordering {
|
||||||
|
fn rank(p: &Priority) -> u8 {
|
||||||
|
match p {
|
||||||
|
Priority::Critical => 4,
|
||||||
|
Priority::High => 3,
|
||||||
|
Priority::Medium => 2,
|
||||||
|
Priority::Low => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rank(self).cmp(&rank(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum CommentType {
|
||||||
|
SingleLine,
|
||||||
|
MultiLine,
|
||||||
|
DocBlock,
|
||||||
|
Shebang,
|
||||||
|
Comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TechDebtItem {
|
||||||
|
pub id: String,
|
||||||
|
pub keyword: String,
|
||||||
|
pub comment_type: CommentType,
|
||||||
|
pub content: String,
|
||||||
|
pub location: FileLocation,
|
||||||
|
pub priority: Priority,
|
||||||
|
pub complexity_score: u8,
|
||||||
|
pub context_before: Option<String>,
|
||||||
|
pub context_after: Option<String>,
|
||||||
|
pub metadata: ItemMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ItemMetadata {
|
||||||
|
pub language: String,
|
||||||
|
pub is_question: bool,
|
||||||
|
pub has_exclamation: bool,
|
||||||
|
pub word_count: usize,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TechDebtItem {
|
||||||
|
pub fn new(
|
||||||
|
keyword: String,
|
||||||
|
content: String,
|
||||||
|
location: FileLocation,
|
||||||
|
language: String,
|
||||||
|
comment_type: CommentType,
|
||||||
|
) -> Self {
|
||||||
|
let complexity_score = calculate_complexity(&content);
|
||||||
|
let is_question = content.contains('?');
|
||||||
|
let has_exclamation = content.contains('!');
|
||||||
|
let word_count = content.split_whitespace().count();
|
||||||
|
|
||||||
|
let priority = Priority::from_keyword(&keyword);
|
||||||
|
|
||||||
|
let id = format!(
|
||||||
|
"{:?}-{:?}-{}",
|
||||||
|
location.path,
|
||||||
|
location.line,
|
||||||
|
keyword
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
keyword,
|
||||||
|
comment_type,
|
||||||
|
content,
|
||||||
|
location,
|
||||||
|
priority,
|
||||||
|
complexity_score,
|
||||||
|
context_before: None,
|
||||||
|
context_after: None,
|
||||||
|
metadata: ItemMetadata {
|
||||||
|
language,
|
||||||
|
is_question,
|
||||||
|
has_exclamation,
|
||||||
|
word_count,
|
||||||
|
created_at: None,
|
||||||
|
author: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_complexity(content: &str) -> u8 {
|
||||||
|
let mut score = 1;
|
||||||
|
|
||||||
|
let question_count = content.matches('?').count();
|
||||||
|
let exclamation_count = content.matches('!').count();
|
||||||
|
let word_count = content.split_whitespace().count();
|
||||||
|
let uppercase_count = content.chars().filter(|c| c.is_uppercase()).count();
|
||||||
|
|
||||||
|
score += (question_count * 2) as u8;
|
||||||
|
score += exclamation_count as u8;
|
||||||
|
score += (word_count / 10) as u8;
|
||||||
|
score += (uppercase_count / 5) as u8;
|
||||||
|
|
||||||
|
if content.len() > 200 {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
if content.len() > 500 {
|
||||||
|
score += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.contains("HACK") || content.contains("WORKAROUND") {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cmp::min(score, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AnalysisSummary {
|
||||||
|
pub total_items: usize,
|
||||||
|
pub by_priority: ByPriority,
|
||||||
|
pub by_language: ByLanguage,
|
||||||
|
pub complexity_distribution: ComplexityDistribution,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ByPriority {
|
||||||
|
pub critical: usize,
|
||||||
|
pub high: usize,
|
||||||
|
pub medium: usize,
|
||||||
|
pub low: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ByPriority {
|
||||||
|
pub fn from_items(items: &[TechDebtItem]) -> Self {
|
||||||
|
let mut critical = 0;
|
||||||
|
let mut high = 0;
|
||||||
|
let mut medium = 0;
|
||||||
|
let mut low = 0;
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
match item.priority {
|
||||||
|
Priority::Critical => critical += 1,
|
||||||
|
Priority::High => high += 1,
|
||||||
|
Priority::Medium => medium += 1,
|
||||||
|
Priority::Low => low += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
critical,
|
||||||
|
high,
|
||||||
|
medium,
|
||||||
|
low,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ByLanguage {
|
||||||
|
pub items: Vec<LanguageCount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LanguageCount {
|
||||||
|
pub language: String,
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ByLanguage {
|
||||||
|
pub fn from_items(items: &[TechDebtItem]) -> Self {
|
||||||
|
let mut counts: std::collections::HashMap<String, usize> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
*counts
|
||||||
|
.entry(item.metadata.language.clone())
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items_vec: Vec<LanguageCount> = counts
|
||||||
|
.into_iter()
|
||||||
|
.map(|(lang, count)| LanguageCount { lang, count })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
items_vec.sort_by(|a, b| b.count.cmp(&a.count));
|
||||||
|
|
||||||
|
Self { items: items_vec }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ComplexityDistribution {
|
||||||
|
pub low: usize,
|
||||||
|
pub medium: usize,
|
||||||
|
pub high: usize,
|
||||||
|
pub critical: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComplexityDistribution {
|
||||||
|
pub fn from_items(items: &[TechDebtItem]) -> Self {
|
||||||
|
let mut low = 0;
|
||||||
|
let mut medium = 0;
|
||||||
|
let mut high = 0;
|
||||||
|
let mut critical = 0;
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
match item.complexity_score {
|
||||||
|
1..=3 => low += 1,
|
||||||
|
4..=6 => medium += 1,
|
||||||
|
7..=8 => high += 1,
|
||||||
|
9..=10 => critical += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
low,
|
||||||
|
medium,
|
||||||
|
high,
|
||||||
|
critical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub patterns: Vec<PatternConfig>,
|
||||||
|
pub languages: Vec<String>,
|
||||||
|
pub ignore: Vec<String>,
|
||||||
|
pub extensions: Vec<String>,
|
||||||
|
pub complexity: ComplexityConfig,
|
||||||
|
pub export: ExportConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
patterns: vec![
|
||||||
|
PatternConfig {
|
||||||
|
keyword: "FIXME".to_string(),
|
||||||
|
priority: Priority::Critical,
|
||||||
|
regex: false,
|
||||||
|
},
|
||||||
|
PatternConfig {
|
||||||
|
keyword: "TODO".to_string(),
|
||||||
|
priority: Priority::Medium,
|
||||||
|
regex: false,
|
||||||
|
},
|
||||||
|
PatternConfig {
|
||||||
|
keyword: "HACK".to_string(),
|
||||||
|
priority: Priority::Low,
|
||||||
|
regex: false,
|
||||||
|
},
|
||||||
|
PatternConfig {
|
||||||
|
keyword: "BUG".to_string(),
|
||||||
|
priority: Priority::Critical,
|
||||||
|
regex: false,
|
||||||
|
},
|
||||||
|
PatternConfig {
|
||||||
|
keyword: "XXX".to_string(),
|
||||||
|
priority: Priority::High,
|
||||||
|
regex: false,
|
||||||
|
},
|
||||||
|
PatternConfig {
|
||||||
|
keyword: "NOTE".to_string(),
|
||||||
|
priority: Priority::Low,
|
||||||
|
regex: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
languages: vec![
|
||||||
|
"javascript".to_string(),
|
||||||
|
"typescript".to_string(),
|
||||||
|
"python".to_string(),
|
||||||
|
"rust".to_string(),
|
||||||
|
"go".to_string(),
|
||||||
|
"java".to_string(),
|
||||||
|
"c".to_string(),
|
||||||
|
"cpp".to_string(),
|
||||||
|
"ruby".to_string(),
|
||||||
|
],
|
||||||
|
ignore: vec![
|
||||||
|
"node_modules/**".to_string(),
|
||||||
|
"target/**".to_string(),
|
||||||
|
".git/**".to_string(),
|
||||||
|
"vendor/**".to_string(),
|
||||||
|
"dist/**".to_string(),
|
||||||
|
"build/**".to_string(),
|
||||||
|
],
|
||||||
|
extensions: vec![
|
||||||
|
".js".to_string(),
|
||||||
|
".ts".to_string(),
|
||||||
|
".jsx".to_string(),
|
||||||
|
".tsx".to_string(),
|
||||||
|
".py".to_string(),
|
||||||
|
".rs".to_string(),
|
||||||
|
".go".to_string(),
|
||||||
|
".java".to_string(),
|
||||||
|
".c".to_string(),
|
||||||
|
".cpp".to_string(),
|
||||||
|
".h".to_string(),
|
||||||
|
".hpp".to_string(),
|
||||||
|
".rb".to_string(),
|
||||||
|
".md".to_string(),
|
||||||
|
],
|
||||||
|
complexity: ComplexityConfig::default(),
|
||||||
|
export: ExportConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PatternConfig {
|
||||||
|
pub keyword: String,
|
||||||
|
pub priority: Priority,
|
||||||
|
pub regex: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ComplexityConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub max_comment_length: usize,
|
||||||
|
pub question_weight: u8,
|
||||||
|
pub exclamation_weight: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ComplexityConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
max_comment_length: 500,
|
||||||
|
question_weight: 2,
|
||||||
|
exclamation_weight: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExportConfig {
|
||||||
|
pub include_metadata: bool,
|
||||||
|
pub include_context: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ExportConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
include_metadata: true,
|
||||||
|
include_context: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
techdebt-tracker-cli/src/tui/app.rs
Normal file
146
techdebt-tracker-cli/src/tui/app.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use crate::tui::{render, TuiState};
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, Event, KeyCode, KeyEventKind},
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType},
|
||||||
|
ExecutableCommand,
|
||||||
|
};
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
use std::io::Stdout;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::models::TechDebtItem;
|
||||||
|
|
||||||
|
pub struct TuiApp {
|
||||||
|
state: TuiState,
|
||||||
|
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiApp {
|
||||||
|
pub fn new(items: Vec<TechDebtItem>, path: PathBuf) -> Self {
|
||||||
|
let state = TuiState::new(items, path);
|
||||||
|
let terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))
|
||||||
|
.expect("Failed to create terminal");
|
||||||
|
|
||||||
|
Self { state, terminal }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&mut self) -> anyhow::Result<()> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
|
||||||
|
std::io::stdout().execute(Clear(ClearType::All))?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
self.terminal.draw(|f| render(f, &self.state))?;
|
||||||
|
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
if key.kind == KeyEventKind::Press {
|
||||||
|
match self.handle_input(key.code) {
|
||||||
|
Ok(should_exit) => {
|
||||||
|
if should_exit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error handling input: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
std::io::stdout().execute(Clear(ClearType::All))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_input(&mut self, code: KeyCode) -> anyhow::Result<bool> {
|
||||||
|
let mut should_exit = false;
|
||||||
|
|
||||||
|
match code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => {
|
||||||
|
should_exit = true;
|
||||||
|
}
|
||||||
|
KeyCode::Tab => {
|
||||||
|
self.state.current_view = match self.state.current_view {
|
||||||
|
crate::tui::View::Dashboard => crate::tui::View::List,
|
||||||
|
crate::tui::View::List => crate::tui::View::Dashboard,
|
||||||
|
crate::tui::View::Detail => crate::tui::View::List,
|
||||||
|
crate::tui::View::Export => crate::tui::View::Dashboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
if self.state.selected_index > 0 {
|
||||||
|
self.state.selected_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
let filtered_len = self.state.filtered_items().len();
|
||||||
|
if filtered_len > 0 && self.state.selected_index < filtered_len - 1 {
|
||||||
|
self.state.selected_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if self.state.current_view == crate::tui::View::List {
|
||||||
|
self.state.current_view = crate::tui::View::Detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
self.enable_filter_mode()?;
|
||||||
|
}
|
||||||
|
KeyCode::Char('f') => {
|
||||||
|
self.enable_filter_mode()?;
|
||||||
|
}
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
self.state.sort_order = match self.state.sort_order {
|
||||||
|
crate::tui::SortOrder::Priority => crate::tui::SortOrder::File,
|
||||||
|
crate::tui::SortOrder::File => crate::tui::SortOrder::Line,
|
||||||
|
crate::tui::SortOrder::Line => crate::tui::SortOrder::Keyword,
|
||||||
|
crate::tui::SortOrder::Keyword => crate::tui::SortOrder::Priority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::Char('c') => {
|
||||||
|
self.state.filter_priority = None;
|
||||||
|
self.state.filter_text.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Char('1') => {
|
||||||
|
self.state.filter_priority = Some(crate::models::Priority::Critical);
|
||||||
|
}
|
||||||
|
KeyCode::Char('2') => {
|
||||||
|
self.state.filter_priority = Some(crate::models::Priority::High);
|
||||||
|
}
|
||||||
|
KeyCode::Char('3') => {
|
||||||
|
self.state.filter_priority = Some(crate::models::Priority::Medium);
|
||||||
|
}
|
||||||
|
KeyCode::Char('4') => {
|
||||||
|
self.state.filter_priority = Some(crate::models::Priority::Low);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(should_exit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enable_filter_mode(&mut self) -> anyhow::Result<()> {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
|
||||||
|
println!("Enter filter text (or press ESC to cancel): ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input)?;
|
||||||
|
|
||||||
|
input = input.trim_end().to_string();
|
||||||
|
|
||||||
|
if input.is_empty() || input.as_bytes().first() == Some(&0x1b) {
|
||||||
|
self.state.filter_text.clear();
|
||||||
|
} else {
|
||||||
|
self.state.filter_text = input;
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
std::io::stdout().execute(Clear(ClearType::All))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
544
techdebt-tracker-cli/src/tui/mod.rs
Normal file
544
techdebt-tracker-cli/src/tui/mod.rs
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
use crossterm::{
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, Clear},
|
||||||
|
ExecutableCommand,
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style, Stylize},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{
|
||||||
|
Block, Borders, Cell, Gauge, List, ListItem, Paragraph, Row, Table, Wrap,
|
||||||
|
},
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
|
use std::io::Stdout;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::models::{AnalysisSummary, ByLanguage, ByPriority, Priority, TechDebtItem};
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub use app::TuiApp;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum View {
|
||||||
|
Dashboard,
|
||||||
|
List,
|
||||||
|
Detail,
|
||||||
|
Export,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
enum SortOrder {
|
||||||
|
Priority,
|
||||||
|
File,
|
||||||
|
Line,
|
||||||
|
Keyword,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TuiState {
|
||||||
|
pub items: Vec<TechDebtItem>,
|
||||||
|
pub current_view: View,
|
||||||
|
pub selected_index: usize,
|
||||||
|
pub filter_text: String,
|
||||||
|
pub filter_priority: Option<Priority>,
|
||||||
|
pub sort_order: SortOrder,
|
||||||
|
pub show_help: bool,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiState {
|
||||||
|
pub fn new(items: Vec<TechDebtItem>, path: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
items,
|
||||||
|
current_view: View::Dashboard,
|
||||||
|
selected_index: 0,
|
||||||
|
filter_text: String::new(),
|
||||||
|
filter_priority: None,
|
||||||
|
sort_order: SortOrder::Priority,
|
||||||
|
show_help: false,
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filtered_items(&self) -> Vec<&TechDebtItem> {
|
||||||
|
let mut items: Vec<&TechDebtItem> = self.items.iter().collect();
|
||||||
|
|
||||||
|
if !self.filter_text.is_empty() {
|
||||||
|
items.retain(|item| {
|
||||||
|
item.content.to_lowercase().contains(&self.filter_text.to_lowercase())
|
||||||
|
|| item
|
||||||
|
.location
|
||||||
|
.path
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&self.filter_text.to_lowercase())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(priority) = self.filter_priority {
|
||||||
|
items.retain(|item| item.priority == priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.sort_order {
|
||||||
|
SortOrder::Priority => items.sort_by(|a, b| b.priority.cmp(&a.priority)),
|
||||||
|
SortOrder::File => items.sort_by(|a, b| {
|
||||||
|
a.location
|
||||||
|
.path
|
||||||
|
.cmp(&b.location.path)
|
||||||
|
.then_with(|| a.location.line.cmp(&b.location.line))
|
||||||
|
}),
|
||||||
|
SortOrder::Line => items.sort_by(|a, b| a.location.line.cmp(&b.location.line)),
|
||||||
|
SortOrder::Keyword => items.sort_by(|a, b| a.keyword.cmp(&b.keyword)),
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summary(&self) -> AnalysisSummary {
|
||||||
|
let by_priority = ByPriority::from_items(&self.items);
|
||||||
|
let by_language = ByLanguage::from_items(&self.items);
|
||||||
|
let complexity_distribution =
|
||||||
|
crate::models::ComplexityDistribution::from_items(&self.items);
|
||||||
|
|
||||||
|
AnalysisSummary {
|
||||||
|
total_items: self.items.len(),
|
||||||
|
by_priority,
|
||||||
|
by_language,
|
||||||
|
complexity_distribution,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_dashboard(f: &mut Frame<CrosstermBackend<Stdout>>, state: &TuiState, area: Rect) {
|
||||||
|
let summary = state.summary();
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let title = Paragraph::new("TECHDEBT TRACKER - Dashboard")
|
||||||
|
.style(Style::default().fg(Color::Cyan).bold());
|
||||||
|
f.render_widget(title, chunks[0]);
|
||||||
|
|
||||||
|
let main_content = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(chunks[1]);
|
||||||
|
|
||||||
|
let left_content = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.split(main_content[0]);
|
||||||
|
|
||||||
|
let right_content = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.split(main_content[1]);
|
||||||
|
|
||||||
|
let priority_block = Block::default()
|
||||||
|
.title("Priority Breakdown")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::White));
|
||||||
|
f.render_widget(priority_block, left_content[0]);
|
||||||
|
|
||||||
|
let priority_content = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(left_content[0].inner);
|
||||||
|
|
||||||
|
let critical_gauge = Gauge::default()
|
||||||
|
.gauge_style(Style::default().fg(Color::Red))
|
||||||
|
.label(&format!("Critical: {}", summary.by_priority.critical))
|
||||||
|
.ratio(if summary.total_items > 0 {
|
||||||
|
summary.by_priority.critical as f64 / summary.total_items as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
});
|
||||||
|
f.render_widget(critical_gauge, priority_content[0]);
|
||||||
|
|
||||||
|
let high_gauge = Gauge::default()
|
||||||
|
.gauge_style(Style::default().fg(Color::Yellow))
|
||||||
|
.label(&format!("High: {}", summary.by_priority.high))
|
||||||
|
.ratio(if summary.total_items > 0 {
|
||||||
|
summary.by_priority.high as f64 / summary.total_items as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
});
|
||||||
|
f.render_widget(high_gauge, priority_content[1]);
|
||||||
|
|
||||||
|
let medium_gauge = Gauge::default()
|
||||||
|
.gauge_style(Style::default().fg(Color::Blue))
|
||||||
|
.label(&format!("Medium: {}", summary.by_priority.medium))
|
||||||
|
.ratio(if summary.total_items > 0 {
|
||||||
|
summary.by_priority.medium as f64 / summary.total_items as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
});
|
||||||
|
f.render_widget(medium_gauge, priority_content[2]);
|
||||||
|
|
||||||
|
let low_gauge = Gauge::default()
|
||||||
|
.gauge_style(Style::default().fg(Color::Green))
|
||||||
|
.label(&format!("Low: {}", summary.by_priority.low))
|
||||||
|
.ratio(if summary.total_items > 0 {
|
||||||
|
summary.by_priority.low as f64 / summary.total_items as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
});
|
||||||
|
f.render_widget(low_gauge, priority_content[3]);
|
||||||
|
|
||||||
|
let total_block = Block::default()
|
||||||
|
.title("Total Items")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::White));
|
||||||
|
f.render_widget(total_block, left_content[1]);
|
||||||
|
let total_text = Paragraph::new(format!("{}", summary.total_items))
|
||||||
|
.style(Style::default().fg(Color::Cyan).bold());
|
||||||
|
f.render_widget(total_text, left_content[1].inner);
|
||||||
|
|
||||||
|
let complexity_block = Block::default()
|
||||||
|
.title("Complexity Distribution")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::White));
|
||||||
|
f.render_widget(complexity_block, left_content[2]);
|
||||||
|
let complexity_text = format!(
|
||||||
|
"Low: {} Medium: {} High: {} Critical: {}",
|
||||||
|
summary.complexity_distribution.low,
|
||||||
|
summary.complexity_distribution.medium,
|
||||||
|
summary.complexity_distribution.high,
|
||||||
|
summary.complexity_distribution.critical
|
||||||
|
);
|
||||||
|
let complexity_para = Paragraph::new(complexity_text);
|
||||||
|
f.render_widget(complexity_para, left_content[2].inner);
|
||||||
|
|
||||||
|
let language_block = Block::default()
|
||||||
|
.title("By Language")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::White));
|
||||||
|
f.render_widget(language_block, right_content[0]);
|
||||||
|
|
||||||
|
let lang_rows: Vec<Row> = summary
|
||||||
|
.by_language
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.take(5)
|
||||||
|
.map(|lang| {
|
||||||
|
Row::new(vec![
|
||||||
|
Cell::from(lang.language.clone()),
|
||||||
|
Cell::from(lang.count.to_string()),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let lang_table = Table::new(lang_rows)
|
||||||
|
.widths(&[Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||||
|
.column_spacing(1);
|
||||||
|
f.render_widget(lang_table, right_content[0].inner);
|
||||||
|
|
||||||
|
let help_block = Block::default()
|
||||||
|
.title("Navigation Help")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::White));
|
||||||
|
f.render_widget(help_block, right_content[1]);
|
||||||
|
let help_text = "↑↓: Navigate | Tab: Switch View | q: Quit | /: Filter";
|
||||||
|
let help_para = Paragraph::new(help_text);
|
||||||
|
f.render_widget(help_para, right_content[1].inner);
|
||||||
|
|
||||||
|
let status_bar = Line::from(vec![
|
||||||
|
Span::styled(" Press ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled("TAB", Style::default().fg(Color::Yellow).bold()),
|
||||||
|
Span::styled(" to view items | ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled("q", Style::default().fg(Color::Yellow).bold()),
|
||||||
|
Span::styled(" to quit", Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
f.render_widget(status_bar, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_list(f: &mut Frame<CrosstermBackend<Stdout>>, state: &TuiState, area: Rect) {
|
||||||
|
let filtered = state.filtered_items();
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let filter_text = if state.filter_text.is_empty() {
|
||||||
|
format!("Filter ({} items) - Press / to filter", filtered.len())
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Filter: {} - {} results (Press / to edit, ESC to clear)",
|
||||||
|
state.filter_text,
|
||||||
|
filtered.len()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let filter_block = Paragraph::new(filter_text)
|
||||||
|
.style(Style::default().fg(Color::Yellow));
|
||||||
|
f.render_widget(filter_block, chunks[0]);
|
||||||
|
|
||||||
|
let list_items: Vec<ListItem> = filtered
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(state.selected_index.saturating_sub(10))
|
||||||
|
.take(chunks[1].height as usize - 2)
|
||||||
|
.map(|(idx, item)| {
|
||||||
|
let priority_color = match item.priority {
|
||||||
|
Priority::Critical => Color::Red,
|
||||||
|
Priority::High => Color::Yellow,
|
||||||
|
Priority::Medium => Color::Blue,
|
||||||
|
Priority::Low => Color::Green,
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = format!(
|
||||||
|
"[{}] {}:{} - {} ({}/10) - {}",
|
||||||
|
item.keyword,
|
||||||
|
item.location.path.file_name().unwrap_or_default().to_string_lossy(),
|
||||||
|
item.location.line,
|
||||||
|
item.content.chars().take(40).collect::<String>(),
|
||||||
|
item.complexity_score,
|
||||||
|
item.metadata.language
|
||||||
|
);
|
||||||
|
|
||||||
|
let style = if *idx == state.selected_index {
|
||||||
|
Style::default()
|
||||||
|
.fg(priority_color)
|
||||||
|
.bg(Color::DarkGray)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(priority_color)
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(content).style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(list_items).block(
|
||||||
|
Block::default()
|
||||||
|
.title(format!("Items ({})", filtered.len()))
|
||||||
|
.borders(Borders::ALL),
|
||||||
|
);
|
||||||
|
f.render_widget(list, chunks[1]);
|
||||||
|
|
||||||
|
let status_bar = Line::from(vec![
|
||||||
|
Span::styled(" ↑↓: Navigate | ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled("ENTER", Style::default().fg(Color::Yellow).bold()),
|
||||||
|
Span::styled(": View Detail | ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled("TAB", Style::default().fg(Color::Yellow).bold()),
|
||||||
|
Span::styled(": Dashboard | ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled("f", Style::default().fg(Color::Yellow).bold()),
|
||||||
|
Span::styled(": Filter | ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled("s", Style::default().fg(Color::Yellow).bold()),
|
||||||
|
Span::styled(": Sort | ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled("q", Style::default().fg(Color::Yellow).bold()),
|
||||||
|
Span::styled(": Quit", Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
f.render_widget(status_bar, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_detail(f: &mut Frame<CrosstermBackend<Stdout>>, state: &TuiState, area: Rect) {
|
||||||
|
let filtered = state.filtered_items();
|
||||||
|
|
||||||
|
if filtered.is_empty() {
|
||||||
|
let para = Paragraph::new("No items selected");
|
||||||
|
f.render_widget(para, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = if let Some(item) = filtered.get(state.selected_index) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
state.selected_index = 0;
|
||||||
|
if let Some(item) = filtered.get(0) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let title = format!(
|
||||||
|
"Detail - {} at {}:{}",
|
||||||
|
item.keyword,
|
||||||
|
item.location.path.display(),
|
||||||
|
item.location.line
|
||||||
|
);
|
||||||
|
let title_block = Paragraph::new(title)
|
||||||
|
.style(Style::default().fg(Color::Cyan).bold());
|
||||||
|
f.render_widget(title_block, chunks[0]);
|
||||||
|
|
||||||
|
let detail_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(chunks[1]);
|
||||||
|
|
||||||
|
let left_details = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.split(detail_chunks[0]);
|
||||||
|
|
||||||
|
let priority_color = match item.priority {
|
||||||
|
Priority::Critical => Color::Red,
|
||||||
|
Priority::High => Color::Yellow,
|
||||||
|
Priority::Medium => Color::Blue,
|
||||||
|
Priority::Low => Color::Green,
|
||||||
|
};
|
||||||
|
|
||||||
|
let priority_line = Line::from(vec![
|
||||||
|
Span::styled("Priority: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(item.priority.as_str(), Style::default().fg(priority_color).bold()),
|
||||||
|
]);
|
||||||
|
f.render_widget(priority_line, left_details[0]);
|
||||||
|
|
||||||
|
let complexity_line = Line::from(vec![
|
||||||
|
Span::styled("Complexity: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
format!("{}/10", item.complexity_score),
|
||||||
|
Style::default()
|
||||||
|
.fg(match item.complexity_score {
|
||||||
|
1..=3 => Color::Green,
|
||||||
|
4..=6 => Color::Yellow,
|
||||||
|
7..=8 => Color::Red,
|
||||||
|
_ => Color::Magenta,
|
||||||
|
})
|
||||||
|
.bold(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(complexity_line, left_details[1]);
|
||||||
|
|
||||||
|
let language_line = Line::from(vec![
|
||||||
|
Span::styled("Language: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(&item.metadata.language, Style::default().fg(Color::Cyan)),
|
||||||
|
]);
|
||||||
|
f.render_widget(language_line, left_details[2]);
|
||||||
|
|
||||||
|
let type_line = Line::from(vec![
|
||||||
|
Span::styled("Type: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
format!("{:?}", item.comment_type),
|
||||||
|
Style::default().fg(Color::Blue),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(type_line, left_details[3]);
|
||||||
|
|
||||||
|
let content_block = Block::default().title("Content").borders(Borders::ALL);
|
||||||
|
f.render_widget(content_block, left_details[4]);
|
||||||
|
let content_para = Paragraph::new(&item.content)
|
||||||
|
.wrap(Wrap { trim: true });
|
||||||
|
f.render_widget(content_para, left_details[4].inner);
|
||||||
|
|
||||||
|
let right_details = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.split(detail_chunks[1]);
|
||||||
|
|
||||||
|
let path_line = Line::from(vec![
|
||||||
|
Span::styled("File: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
item.location.path.display().to_string(),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(path_line, right_details[0]);
|
||||||
|
|
||||||
|
let location_line = Line::from(vec![
|
||||||
|
Span::styled("Line: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
item.location.line.to_string(),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
),
|
||||||
|
Span::styled(" | Column: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
item.location.column.to_string(),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(location_line, right_details[1]);
|
||||||
|
|
||||||
|
let question_line = Line::from(vec![
|
||||||
|
Span::styled("Contains Question: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(
|
||||||
|
if item.metadata.is_question { "Yes" } else { "No" },
|
||||||
|
Style::default().fg(if item.metadata.is_question {
|
||||||
|
Color::Yellow
|
||||||
|
} else {
|
||||||
|
Color::Green
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(question_line, right_details[2]);
|
||||||
|
|
||||||
|
let word_count_line = Line::from(vec![
|
||||||
|
Span::styled("Word Count: ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(item.metadata.word_count.to_string(), Style::default().fg(Color::White)),
|
||||||
|
]);
|
||||||
|
f.render_widget(word_count_line, right_details[3]);
|
||||||
|
|
||||||
|
let status_bar = Line::from(vec![
|
||||||
|
Span::styled(" ←: Back | ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled("e", Style::default().fg(Color::Yellow).bold()),
|
||||||
|
Span::styled(": Export | ", Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled("q", Style::default().fg(Color::Yellow).bold()),
|
||||||
|
Span::styled(": Quit", Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
f.render_widget(status_bar, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame<CrosstermBackend<Stdout>>, state: &TuiState) {
|
||||||
|
let area = f.size();
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.style(Style::default().bg(Color::Black))
|
||||||
|
.borders(Borders::NONE);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
|
||||||
|
match state.current_view {
|
||||||
|
View::Dashboard => draw_dashboard(f, state, area),
|
||||||
|
View::List => draw_list(f, state, area),
|
||||||
|
View::Detail => draw_detail(f, state, area),
|
||||||
|
View::Export => {
|
||||||
|
let para = Paragraph::new("Export feature - Coming soon!");
|
||||||
|
f.render_widget(para, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
techdebt-tracker-cli/tests/analysis_tests.rs
Normal file
52
techdebt-tracker-cli/tests/analysis_tests.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use techdebt_tracker_cli::models::{Priority, TechDebtItem, FileLocation};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_priority_from_keyword() {
|
||||||
|
assert_eq!(Priority::from_keyword("FIXME"), Priority::Critical);
|
||||||
|
assert_eq!(Priority::from_keyword("BUG"), Priority::Critical);
|
||||||
|
assert_eq!(Priority::from_keyword("TODO"), Priority::Medium);
|
||||||
|
assert_eq!(Priority::from_keyword("HACK"), Priority::Low);
|
||||||
|
assert_eq!(Priority::from_keyword("XXX"), Priority::High);
|
||||||
|
assert_eq!(Priority::from_keyword("NOTE"), Priority::Low);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_priority_ordering() {
|
||||||
|
assert!(Priority::Critical > Priority::High);
|
||||||
|
assert!(Priority::High > Priority::Medium);
|
||||||
|
assert!(Priority::Medium > Priority::Low);
|
||||||
|
assert!(Priority::Low < Priority::Critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_priority_as_str() {
|
||||||
|
assert_eq!(Priority::Critical.as_str(), "Critical");
|
||||||
|
assert_eq!(Priority::High.as_str(), "High");
|
||||||
|
assert_eq!(Priority::Medium.as_str(), "Medium");
|
||||||
|
assert_eq!(Priority::Low.as_str(), "Low");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tech_debt_item_creation() {
|
||||||
|
let location = FileLocation {
|
||||||
|
path: PathBuf::from("/test/file.rs"),
|
||||||
|
line: 10,
|
||||||
|
column: 5,
|
||||||
|
end_line: None,
|
||||||
|
end_column: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = TechDebtItem::new(
|
||||||
|
"FIXME".to_string(),
|
||||||
|
"This is a test fixme".to_string(),
|
||||||
|
location,
|
||||||
|
"Rust".to_string(),
|
||||||
|
techdebt_tracker_cli::models::CommentType::SingleLine,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(item.keyword, "FIXME");
|
||||||
|
assert_eq!(item.priority, Priority::Critical);
|
||||||
|
assert!(!item.id.is_empty());
|
||||||
|
}
|
||||||
47
techdebt-tracker-cli/tests/cli_tests.rs
Normal file
47
techdebt-tracker-cli/tests/cli_tests.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use assert_cmd::Command;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_help() {
|
||||||
|
let mut cmd = Command::cargo_bin("techdebt-tracker-cli").unwrap();
|
||||||
|
cmd.arg("--help")
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cli_version() {
|
||||||
|
let mut cmd = Command::cargo_bin("techdebt-tracker-cli").unwrap();
|
||||||
|
cmd.arg("--version")
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_command() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let temp_path = temp_dir.path().to_path_buf();
|
||||||
|
|
||||||
|
let mut cmd = Command::cargo_bin("techdebt-tracker-cli").unwrap();
|
||||||
|
cmd.arg("init")
|
||||||
|
.arg("--path")
|
||||||
|
.arg(&temp_path)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let config_path = temp_path.join("techdebt.yaml");
|
||||||
|
assert!(config_path.exists());
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&config_path).unwrap();
|
||||||
|
assert!(content.contains("patterns:"));
|
||||||
|
assert!(content.contains("languages:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_command_nonexistent_path() {
|
||||||
|
let mut cmd = Command::cargo_bin("techdebt-tracker-cli").unwrap();
|
||||||
|
cmd.arg("analyze")
|
||||||
|
.arg("/nonexistent/path")
|
||||||
|
.assert()
|
||||||
|
.failure();
|
||||||
|
}
|
||||||
81
techdebt-tracker-cli/tests/fixtures/sample.js
vendored
Normal file
81
techdebt-tracker-cli/tests/fixtures/sample.js
vendored
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// JavaScript sample file with technical debt comments
|
||||||
|
|
||||||
|
function calculateTotal(items) {
|
||||||
|
// TODO: This should use reduce instead of a loop
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
total += items[i].price;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchUserData(userId) {
|
||||||
|
// FIXME: API error handling is missing
|
||||||
|
// FIXME: This endpoint may return null
|
||||||
|
return fetch(`/api/users/${userId}`)
|
||||||
|
.then(response => response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
function processPayment(amount) {
|
||||||
|
// HACK: Quick fix for holiday season
|
||||||
|
if (amount > 1000) {
|
||||||
|
console.log("High value transaction");
|
||||||
|
}
|
||||||
|
// TODO: Implement proper payment processing
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUser(name, email) {
|
||||||
|
// NOTE: Email validation is basic
|
||||||
|
if (!email.includes('@')) {
|
||||||
|
throw new Error('Invalid email');
|
||||||
|
}
|
||||||
|
return { name, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
class DataProcessor {
|
||||||
|
constructor() {
|
||||||
|
this.data = [];
|
||||||
|
// XXX: Memory leak - data is never cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem(item) {
|
||||||
|
this.data.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
process() {
|
||||||
|
// BUG: This doesn't handle edge cases
|
||||||
|
return this.data.map(x => x * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add JSDoc comments
|
||||||
|
// TODO: Write unit tests
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
// FIXME: Hardcoded path should be configurable
|
||||||
|
const response = await fetch('/config.json');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function temporaryFix() {
|
||||||
|
// TEMP: Remove this after Q1
|
||||||
|
return { status: 'pending' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function oldCode() {
|
||||||
|
// REFACTOR: This code is legacy
|
||||||
|
let result = 0;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
result += i;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
calculateTotal,
|
||||||
|
fetchUserData,
|
||||||
|
processPayment,
|
||||||
|
createUser,
|
||||||
|
DataProcessor,
|
||||||
|
loadConfig,
|
||||||
|
};
|
||||||
94
techdebt-tracker-cli/tests/fixtures/sample.py
vendored
Normal file
94
techdebt-tracker-cli/tests/fixtures/sample.py
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Python sample file with technical debt comments
|
||||||
|
|
||||||
|
def calculate_average(numbers):
|
||||||
|
# TODO: Handle empty list case
|
||||||
|
return sum(numbers) / len(numbers)
|
||||||
|
|
||||||
|
|
||||||
|
def process_user(user_data):
|
||||||
|
# FIXME: This may raise KeyError for missing fields
|
||||||
|
name = user_data['name']
|
||||||
|
email = user_data['email']
|
||||||
|
return {'name': name, 'email': email}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_data_from_api(endpoint):
|
||||||
|
# HACK: Skip SSL verification for testing
|
||||||
|
import requests
|
||||||
|
response = requests.get(endpoint, verify=False)
|
||||||
|
# TODO: Add retry logic
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseConnection:
|
||||||
|
def __init__(self, connection_string):
|
||||||
|
# FIXME: Connection string should be encrypted
|
||||||
|
self.connection_string = connection_string
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
# BUG: Connection timeout not implemented
|
||||||
|
print("Connecting to database...")
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
# NOTE: Pool cleanup happens automatically
|
||||||
|
print("Disconnecting...")
|
||||||
|
|
||||||
|
|
||||||
|
def temp_workaround():
|
||||||
|
# TEMP: Quick fix for production issue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def old_implementation():
|
||||||
|
# REFACTOR: Use list comprehension instead
|
||||||
|
result = []
|
||||||
|
for i in range(10):
|
||||||
|
result.append(i * 2)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def validate_input(data):
|
||||||
|
# XXX: Critical security vulnerability!
|
||||||
|
# This eval is dangerous
|
||||||
|
return eval(data) # nosec
|
||||||
|
|
||||||
|
|
||||||
|
def complex_function():
|
||||||
|
# TODO: This function is too long, split it up
|
||||||
|
# TODO: Add type hints
|
||||||
|
# TODO: Add docstring
|
||||||
|
x = 1
|
||||||
|
y = 2
|
||||||
|
z = 3
|
||||||
|
a = 4
|
||||||
|
b = 5
|
||||||
|
return x + y + z + a + b
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyClass:
|
||||||
|
"""This class needs refactoring."""
|
||||||
|
def __init__(self):
|
||||||
|
self._internal_state = None
|
||||||
|
# FIXME: Memory leak risk
|
||||||
|
|
||||||
|
def _old_method(self):
|
||||||
|
# NOTE: This is deprecated
|
||||||
|
pass
|
||||||
|
|
||||||
|
def new_method(self):
|
||||||
|
"""Modern replacement for _old_method."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Add exception handling
|
||||||
|
# TODO: Write docstrings for all public methods
|
||||||
|
# TODO: Add unit tests
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = [1, 2, 3, 4, 5]
|
||||||
|
avg = calculate_average(data)
|
||||||
|
print(f"Average: {avg}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
60
techdebt-tracker-cli/tests/fixtures/sample.rs
vendored
Normal file
60
techdebt-tracker-cli/tests/fixtures/sample.rs
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// This is a Rust file with various TODO/FIXME comments
|
||||||
|
|
||||||
|
fn calculate_sum(a: i32, b: i32) -> i32 {
|
||||||
|
// TODO: Implement proper error handling
|
||||||
|
a + b
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_data(data: &[u8]) -> Result<(), String> {
|
||||||
|
// FIXME: This function has a bug with empty data
|
||||||
|
if data.is_empty() {
|
||||||
|
return Err("No data provided".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complex_function(x: i32) -> i32 {
|
||||||
|
// HACK: This is a workaround for a dependency issue
|
||||||
|
// TODO: Refactor this when the library is updated
|
||||||
|
x * 2 + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn another_function() {
|
||||||
|
// NOTE: This function is deprecated
|
||||||
|
// TODO: Remove in next version
|
||||||
|
println!("This is deprecated");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct User {
|
||||||
|
id: u32,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
fn new(id: u32, name: String) -> Self {
|
||||||
|
// FIXME: Validation is missing here!
|
||||||
|
Self { id, name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX: This is a critical issue that needs immediate attention
|
||||||
|
fn critical_function() {
|
||||||
|
// BUG: Memory leak detected here
|
||||||
|
let _data = vec![1, 2, 3];
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement unit tests for this module
|
||||||
|
// TODO: Add documentation comments
|
||||||
|
|
||||||
|
fn temp_impl() {
|
||||||
|
// TEMP: Quick fix for release
|
||||||
|
println!("temp");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refactor_needed() {
|
||||||
|
// REFACTOR: This code is hard to maintain
|
||||||
|
let x = 1;
|
||||||
|
let y = 2;
|
||||||
|
let z = 3;
|
||||||
|
println!("{} {} {}", x, y, z);
|
||||||
|
}
|
||||||
113
techdebt-tracker-cli/tests/parser_tests.rs
Normal file
113
techdebt-tracker-cli/tests/parser_tests.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use techdebt_tracker_cli::core::language::{Language, LanguageParser};
|
||||||
|
use techdebt_tracker_cli::models::{Config, PatternConfig, Priority};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_language_from_path_js() {
|
||||||
|
let path = PathBuf::from("test.js");
|
||||||
|
assert_eq!(Language::from_path(&path), Some(Language::JavaScript));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_language_from_path_ts() {
|
||||||
|
let path = PathBuf::from("test.ts");
|
||||||
|
assert_eq!(Language::from_path(&path), Some(Language::TypeScript));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_language_from_path_py() {
|
||||||
|
let path = PathBuf::from("test.py");
|
||||||
|
assert_eq!(Language::from_path(&path), Some(Language::Python));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_language_from_path_rs() {
|
||||||
|
let path = PathBuf::from("test.rs");
|
||||||
|
assert_eq!(Language::from_path(&path), Some(Language::Rust));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_language_from_path_unknown() {
|
||||||
|
let path = PathBuf::from("test.xyz");
|
||||||
|
assert_eq!(Language::from_path(&path), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_language_single_line_comment() {
|
||||||
|
assert_eq!(Language::JavaScript.single_line_comment(), Some("//"));
|
||||||
|
assert_eq!(Language::Python.single_line_comment(), Some("#"));
|
||||||
|
assert_eq!(Language::Rust.single_line_comment(), Some("//"));
|
||||||
|
assert_eq!(Language::Unknown.single_line_comment(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_language_as_str() {
|
||||||
|
assert_eq!(Language::JavaScript.as_str(), "JavaScript");
|
||||||
|
assert_eq!(Language::Python.as_str(), "Python");
|
||||||
|
assert_eq!(Language::Rust.as_str(), "Rust");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_javascript_comment_parsing() {
|
||||||
|
let content = r#"
|
||||||
|
function test() {
|
||||||
|
// TODO: Implement this
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let parser = LanguageParser::new(Language::JavaScript);
|
||||||
|
let patterns = vec![PatternConfig {
|
||||||
|
keyword: "TODO".to_string(),
|
||||||
|
priority: Priority::Medium,
|
||||||
|
regex: false,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let items = parser.parse(content, &PathBuf::from("test.js"), &patterns).unwrap();
|
||||||
|
|
||||||
|
assert!(!items.is_empty());
|
||||||
|
assert!(items.iter().any(|i| i.keyword == "TODO"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_python_comment_parsing() {
|
||||||
|
let content = r#"
|
||||||
|
def test():
|
||||||
|
# FIXME: This is broken
|
||||||
|
return True
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let parser = LanguageParser::new(Language::Python);
|
||||||
|
let patterns = vec![PatternConfig {
|
||||||
|
keyword: "FIXME".to_string(),
|
||||||
|
priority: Priority::Critical,
|
||||||
|
regex: false,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let items = parser.parse(content, &PathBuf::from("test.py"), &patterns).unwrap();
|
||||||
|
|
||||||
|
assert!(!items.is_empty());
|
||||||
|
assert!(items.iter().any(|i| i.keyword == "FIXME"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiline_comment_parsing() {
|
||||||
|
let content = r#"
|
||||||
|
/*
|
||||||
|
* TODO: Multi-line comment
|
||||||
|
* Need to fix this later
|
||||||
|
*/
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let parser = LanguageParser::new(Language::JavaScript);
|
||||||
|
let patterns = vec![PatternConfig {
|
||||||
|
keyword: "TODO".to_string(),
|
||||||
|
priority: Priority::Medium,
|
||||||
|
regex: false,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let items = parser.parse(content, &PathBuf::from("test.js"), &patterns).unwrap();
|
||||||
|
|
||||||
|
assert!(!items.is_empty());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user