Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6097c92ef9 | |||
| dbe7f6c702 | |||
| 285ced2817 | |||
| 4aeec3f8b5 | |||
| d0ffa09d10 | |||
| 79566f8b3e | |||
| e4045eccfe | |||
| 3d0cd8eb1e | |||
| 24369c5e4e | |||
| aa72b301c5 | |||
| 4e8d2b6d79 | |||
| 38f2b0a0f5 | |||
| db232dbf3d | |||
| d6c6f16fd4 | |||
| 06527a52a4 | |||
| addedc1e4d | |||
| a2ae510563 |
@@ -2,38 +2,51 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout: 600
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout code
|
||||||
- uses: actions/setup-python@v5
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: actions-rs/toolchain@v1
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
toolchain: stable
|
||||||
- name: Install Rust
|
profile: minimal
|
||||||
run: |
|
override: true
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
- name: Cache cargo registry
|
||||||
source $HOME/.cargo/env
|
uses: actions/cache@v4
|
||||||
rustc --version
|
with:
|
||||||
cargo --version
|
path: ~/.cargo/registry
|
||||||
- name: Build
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
run: |
|
restore-keys: |
|
||||||
source $HOME/.cargo/env
|
${{ runner.os }}-cargo-registry-
|
||||||
cargo build --release
|
|
||||||
|
- 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
|
- name: Run tests
|
||||||
run: |
|
run: cargo test --all-features
|
||||||
source $HOME/.cargo/env
|
|
||||||
cargo test --all-features
|
- name: Run clippy
|
||||||
- name: Clippy
|
run: cargo clippy --all-features -- -D warnings
|
||||||
run: |
|
|
||||||
source $HOME/.cargo/env
|
- name: Check formatting
|
||||||
cargo clippy --all-targets
|
run: cargo fmt --check -- --color=never
|
||||||
- name: Format check
|
|
||||||
run: |
|
|
||||||
source $HOME/.cargo/env
|
|
||||||
cargo fmt --check
|
|
||||||
|
|||||||
27
app/techdebt-tracker-cli/.gitea/workflows/ci.yml
Normal file
27
app/techdebt-tracker-cli/.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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: Build project
|
||||||
|
run: cargo build --all-features
|
||||||
41
app/techdebt-tracker-cli/Cargo.toml
Normal file
41
app/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]
|
||||||
148
app/techdebt-tracker-cli/src/core/analyzer.rs
Normal file
148
app/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
app/techdebt-tracker-cli/src/core/language.rs
Normal file
370
app/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
app/techdebt-tracker-cli/src/core/mod.rs
Normal file
11
app/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
app/techdebt-tracker-cli/src/export/exporter.rs
Normal file
181
app/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
app/techdebt-tracker-cli/src/export/mod.rs
Normal file
6
app/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;
|
||||||
530
app/techdebt-tracker-cli/src/tui/mod.rs
Normal file
530
app/techdebt-tracker-cli/src/tui/mod.rs
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
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(" ", 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user