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:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout: 600
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
source $HOME/.cargo/env
|
||||
rustc --version
|
||||
cargo --version
|
||||
- name: Build
|
||||
run: |
|
||||
source $HOME/.cargo/env
|
||||
cargo build --release
|
||||
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: |
|
||||
source $HOME/.cargo/env
|
||||
cargo test --all-features
|
||||
- name: Clippy
|
||||
run: |
|
||||
source $HOME/.cargo/env
|
||||
cargo clippy --all-targets
|
||||
- name: Format check
|
||||
run: |
|
||||
source $HOME/.cargo/env
|
||||
cargo fmt --check
|
||||
run: cargo test --all-features
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --all-features -- -D warnings
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check -- --color=never
|
||||
|
||||
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