From 9ee4c9a574552b3adc8854f114ccac7d80fbf0a6 Mon Sep 17 00:00:00 2001 From: "Azat S." Date: Sun, 3 Nov 2024 19:57:24 +0300 Subject: [PATCH] refactor: improve project structure --- src/{ => comments}/get_comments.rs | 0 .../identify_not_ignored_file.rs | 0 src/{ => comments}/identify_supported_file.rs | 0 src/{ => comments}/identify_todo_comment.rs | 0 src/comments/mod.rs | 11 + src/{ => comments}/prepare_blame_data.rs | 2 +- src/{ => fs}/copy_dir_recursive.rs | 0 src/{ => fs}/get_current_directory.rs | 3 +- src/{ => fs}/get_dist_path.rs | 4 +- src/fs/mod.rs | 7 + src/{ => git}/check_git_repository.rs | 0 src/{blame.rs => git/get_blame_data.rs} | 2 +- src/{ => git}/get_file_by_commit.rs | 0 src/{ => git}/get_files_list.rs | 0 src/{ => git}/get_history.rs | 0 src/{ => git}/get_last_commit_hash.rs | 0 src/{ => git}/get_line_from_position.rs | 0 src/{ => git}/get_modified_files.rs | 0 src/git/mod.rs | 17 + src/lib.rs | 27 +- src/main.rs | 404 ++---------------- src/project/check_git_repository_or_exit.rs | 9 + src/project/collect_todo_data.rs | 90 ++++ src/project/collect_todo_history.rs | 141 ++++++ src/project/enrich_todo_data_with_blame.rs | 49 +++ src/project/generate_output.rs | 67 +++ src/project/get_filtered_files.rs | 14 + src/{ => project}/get_project_name.rs | 2 +- src/{ => project}/get_todoctor_version.rs | 2 +- src/project/mod.rs | 19 + src/project/prepare_json_data.rs | 23 + src/types.rs | 9 +- src/{ => utils}/add_missing_days.rs | 0 src/{ => utils}/escape_html.rs | 0 src/{ => utils}/escape_json_values.rs | 2 +- src/utils/mod.rs | 9 + src/{ => utils}/remove_duplicate_dates.rs | 0 tests/check_git_repository.rs | 2 +- tests/identify_todo_comment.rs | 2 +- 39 files changed, 517 insertions(+), 400 deletions(-) rename src/{ => comments}/get_comments.rs (100%) rename src/{ => comments}/identify_not_ignored_file.rs (100%) rename src/{ => comments}/identify_supported_file.rs (100%) rename src/{ => comments}/identify_todo_comment.rs (100%) create mode 100644 src/comments/mod.rs rename src/{ => comments}/prepare_blame_data.rs (96%) rename src/{ => fs}/copy_dir_recursive.rs (100%) rename src/{ => fs}/get_current_directory.rs (86%) rename src/{ => fs}/get_dist_path.rs (93%) create mode 100644 src/fs/mod.rs rename src/{ => git}/check_git_repository.rs (100%) rename src/{blame.rs => git/get_blame_data.rs} (96%) rename src/{ => git}/get_file_by_commit.rs (100%) rename src/{ => git}/get_files_list.rs (100%) rename src/{ => git}/get_history.rs (100%) rename src/{ => git}/get_last_commit_hash.rs (100%) rename src/{ => git}/get_line_from_position.rs (100%) rename src/{ => git}/get_modified_files.rs (100%) create mode 100644 src/git/mod.rs create mode 100644 src/project/check_git_repository_or_exit.rs create mode 100644 src/project/collect_todo_data.rs create mode 100644 src/project/collect_todo_history.rs create mode 100644 src/project/enrich_todo_data_with_blame.rs create mode 100644 src/project/generate_output.rs create mode 100644 src/project/get_filtered_files.rs rename src/{ => project}/get_project_name.rs (81%) rename src/{ => project}/get_todoctor_version.rs (96%) create mode 100644 src/project/mod.rs create mode 100644 src/project/prepare_json_data.rs rename src/{ => utils}/add_missing_days.rs (100%) rename src/{ => utils}/escape_html.rs (100%) rename src/{ => utils}/escape_json_values.rs (93%) create mode 100644 src/utils/mod.rs rename src/{ => utils}/remove_duplicate_dates.rs (100%) diff --git a/src/get_comments.rs b/src/comments/get_comments.rs similarity index 100% rename from src/get_comments.rs rename to src/comments/get_comments.rs diff --git a/src/identify_not_ignored_file.rs b/src/comments/identify_not_ignored_file.rs similarity index 100% rename from src/identify_not_ignored_file.rs rename to src/comments/identify_not_ignored_file.rs diff --git a/src/identify_supported_file.rs b/src/comments/identify_supported_file.rs similarity index 100% rename from src/identify_supported_file.rs rename to src/comments/identify_supported_file.rs diff --git a/src/identify_todo_comment.rs b/src/comments/identify_todo_comment.rs similarity index 100% rename from src/identify_todo_comment.rs rename to src/comments/identify_todo_comment.rs diff --git a/src/comments/mod.rs b/src/comments/mod.rs new file mode 100644 index 0000000..4655670 --- /dev/null +++ b/src/comments/mod.rs @@ -0,0 +1,11 @@ +pub use self::get_comments::get_comments; +pub use self::identify_not_ignored_file::identify_not_ignored_file; +pub use self::identify_supported_file::identify_supported_file; +pub use self::identify_todo_comment::identify_todo_comment; +pub use self::prepare_blame_data::prepare_blame_data; + +pub mod get_comments; +pub mod identify_not_ignored_file; +pub mod identify_supported_file; +pub mod identify_todo_comment; +pub mod prepare_blame_data; diff --git a/src/prepare_blame_data.rs b/src/comments/prepare_blame_data.rs similarity index 96% rename from src/prepare_blame_data.rs rename to src/comments/prepare_blame_data.rs index e72c0c0..060a60e 100644 --- a/src/prepare_blame_data.rs +++ b/src/comments/prepare_blame_data.rs @@ -1,4 +1,4 @@ -use crate::blame::BlameData; +use crate::git::get_blame_data::BlameData; use chrono::{DateTime, Duration, Utc}; use serde::Serialize; diff --git a/src/copy_dir_recursive.rs b/src/fs/copy_dir_recursive.rs similarity index 100% rename from src/copy_dir_recursive.rs rename to src/fs/copy_dir_recursive.rs diff --git a/src/get_current_directory.rs b/src/fs/get_current_directory.rs similarity index 86% rename from src/get_current_directory.rs rename to src/fs/get_current_directory.rs index c1acc35..58ca995 100644 --- a/src/get_current_directory.rs +++ b/src/fs/get_current_directory.rs @@ -1,5 +1,4 @@ -use std::env; -use std::path::PathBuf; +use std::{env, path::PathBuf}; pub fn get_current_directory() -> Option { match env::current_dir() { diff --git a/src/get_dist_path.rs b/src/fs/get_dist_path.rs similarity index 93% rename from src/get_dist_path.rs rename to src/fs/get_dist_path.rs index 1e50338..ead79a4 100644 --- a/src/get_dist_path.rs +++ b/src/fs/get_dist_path.rs @@ -1,6 +1,4 @@ -use std::env; -use std::fs; -use std::path::PathBuf; +use std::{env, fs, path::PathBuf}; pub fn get_dist_path() -> Option { let exe_path = match env::current_exe() { diff --git a/src/fs/mod.rs b/src/fs/mod.rs new file mode 100644 index 0000000..dcf44d9 --- /dev/null +++ b/src/fs/mod.rs @@ -0,0 +1,7 @@ +pub use self::copy_dir_recursive::copy_dir_recursive; +pub use self::get_current_directory::get_current_directory; +pub use self::get_dist_path::get_dist_path; + +pub mod copy_dir_recursive; +pub mod get_current_directory; +pub mod get_dist_path; diff --git a/src/check_git_repository.rs b/src/git/check_git_repository.rs similarity index 100% rename from src/check_git_repository.rs rename to src/git/check_git_repository.rs diff --git a/src/blame.rs b/src/git/get_blame_data.rs similarity index 96% rename from src/blame.rs rename to src/git/get_blame_data.rs index f8ae06b..becf160 100644 --- a/src/blame.rs +++ b/src/git/get_blame_data.rs @@ -11,7 +11,7 @@ pub struct BlameData { pub author: String, } -pub async fn blame(path: &str, line: u32) -> Option { +pub async fn get_blame_data(path: &str, line: u32) -> Option { let result = match exec(&[ "git", "blame", diff --git a/src/get_file_by_commit.rs b/src/git/get_file_by_commit.rs similarity index 100% rename from src/get_file_by_commit.rs rename to src/git/get_file_by_commit.rs diff --git a/src/get_files_list.rs b/src/git/get_files_list.rs similarity index 100% rename from src/get_files_list.rs rename to src/git/get_files_list.rs diff --git a/src/get_history.rs b/src/git/get_history.rs similarity index 100% rename from src/get_history.rs rename to src/git/get_history.rs diff --git a/src/get_last_commit_hash.rs b/src/git/get_last_commit_hash.rs similarity index 100% rename from src/get_last_commit_hash.rs rename to src/git/get_last_commit_hash.rs diff --git a/src/get_line_from_position.rs b/src/git/get_line_from_position.rs similarity index 100% rename from src/get_line_from_position.rs rename to src/git/get_line_from_position.rs diff --git a/src/get_modified_files.rs b/src/git/get_modified_files.rs similarity index 100% rename from src/get_modified_files.rs rename to src/git/get_modified_files.rs diff --git a/src/git/mod.rs b/src/git/mod.rs new file mode 100644 index 0000000..34729f5 --- /dev/null +++ b/src/git/mod.rs @@ -0,0 +1,17 @@ +pub use self::check_git_repository::check_git_repository; +pub use self::get_blame_data::get_blame_data; +pub use self::get_file_by_commit::get_file_by_commit; +pub use self::get_files_list::get_files_list; +pub use self::get_history::get_history; +pub use self::get_last_commit_hash::get_last_commit_hash; +pub use self::get_line_from_position::get_line_from_position; +pub use self::get_modified_files::get_modified_files; + +pub mod check_git_repository; +pub mod get_blame_data; +pub mod get_file_by_commit; +pub mod get_files_list; +pub mod get_history; +pub mod get_last_commit_hash; +pub mod get_line_from_position; +pub mod get_modified_files; diff --git a/src/lib.rs b/src/lib.rs index 5a98516..1eb2b8e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,24 +1,7 @@ -pub mod add_missing_days; -pub mod blame; -pub mod check_git_repository; -pub mod copy_dir_recursive; -pub mod escape_html; -pub mod escape_json_values; +pub mod comments; pub mod exec; -pub mod get_comments; -pub mod get_current_directory; -pub mod get_dist_path; -pub mod get_file_by_commit; -pub mod get_files_list; -pub mod get_history; -pub mod get_last_commit_hash; -pub mod get_line_from_position; -pub mod get_modified_files; -pub mod get_project_name; -pub mod get_todoctor_version; -pub mod identify_not_ignored_file; -pub mod identify_supported_file; -pub mod identify_todo_comment; -pub mod prepare_blame_data; -pub mod remove_duplicate_dates; +pub mod fs; +pub mod git; +pub mod project; pub mod types; +pub mod utils; diff --git a/src/main.rs b/src/main.rs index 65cb9ab..f9ced9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,43 +1,10 @@ -use clap::{ArgAction, CommandFactory, Parser, ValueEnum}; -use indicatif::{ProgressBar, ProgressStyle}; -use open; -use serde_json::{json, Value}; -use std::collections::HashMap; -use std::fs::File; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::process; -use std::sync::Arc; -use todoctor::add_missing_days::add_missing_days; -use todoctor::blame::blame; -use todoctor::check_git_repository::check_git_repository; -use todoctor::copy_dir_recursive::copy_dir_recursive; -use todoctor::escape_json_values::escape_json_values; -use todoctor::get_comments::get_comments; -use todoctor::get_current_directory::get_current_directory; -use todoctor::get_dist_path::get_dist_path; -use todoctor::get_file_by_commit::get_file_by_commit; -use todoctor::get_files_list::get_files_list; -use todoctor::get_history::get_history; -use todoctor::get_last_commit_hash::get_last_commit_hash; -use todoctor::get_line_from_position::get_line_from_position; -use todoctor::get_modified_files::get_modified_files; -use todoctor::get_project_name::get_project_name; -use todoctor::get_todoctor_version::get_todoctor_version; -use todoctor::identify_not_ignored_file::identify_not_ignored_file; -use todoctor::identify_supported_file::identify_supported_file; -use todoctor::identify_todo_comment::identify_todo_comment; -use todoctor::prepare_blame_data::{prepare_blame_data, PreparedBlameData}; -use todoctor::remove_duplicate_dates::remove_duplicate_dates; -use todoctor::types::{TodoData, TodoHistory, TodoWithBlame}; -use tokio::fs; -use tokio::sync::Mutex; - -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] -enum OutputFormat { - Html, - Json, -} +use clap::{ArgAction, CommandFactory, Parser}; +use todoctor::project::{ + check_git_repository_or_exit, collect_todo_data, collect_todo_history, + enrich_todo_data_with_blame, generate_output, get_filtered_files, + get_todoctor_version, prepare_json_data, +}; +use todoctor::types::OutputFormat; #[derive(Parser, Debug)] #[command( @@ -79,342 +46,49 @@ async fn main() { .await .unwrap_or_else(|| "Unknown version".to_string()); - let version_for_cli = version.clone(); - let version_static: &'static str = - Box::leak(version_for_cli.into_boxed_str()); - - let args = Cli::command().version(version_static).get_matches(); - - let months = args.get_one::("month").unwrap(); - let ignores: Vec = args - .get_many::("ignore") - .map(|values| values.map(String::from).collect()) - .unwrap_or_else(Vec::new); - - let include_keywords: Vec = args - .get_many::("include_keywords") - .map(|values| values.map(String::from).collect()) - .unwrap_or_else(Vec::new); - - let exclude_keywords: Vec = args - .get_many::("exclude_keywords") - .map(|values| values.map(String::from).collect()) - .unwrap_or_else(Vec::new); - - let output_format = args.get_one::("output_format").unwrap(); - - let output_directory = args.get_one::("output").unwrap(); - - if !check_git_repository(".").await { - eprintln!("Error: This is not a Git repository."); - process::exit(1); - } - - let files_list = get_files_list(None).await.unwrap(); - - let files: Vec = files_list - .into_iter() - .filter(|file| { - identify_not_ignored_file(file, &ignores) - && identify_supported_file(file) - }) - .collect(); - - let todo_counts = Arc::new(Mutex::new(HashMap::new())); - let todo_counts_clone = Arc::clone(&todo_counts); - - let mut todo_history_data: Vec = Vec::new(); - - let todo_data_tasks: Vec<_> = files - .into_iter() - .map(|source_file_name: String| { - let include_keywords = include_keywords.clone(); - let exclude_keywords = exclude_keywords.clone(); - let todo_counts = Arc::clone(&todo_counts_clone); - - tokio::spawn(async move { - match fs::read_to_string(&source_file_name).await { - Ok(source) => { - let comments = get_comments(&source, &source_file_name); - let todos: Vec = comments - .into_iter() - .filter_map(|comment| { - let include_keywords_refs: Vec<&str> = - include_keywords - .iter() - .map(|s| s.as_str()) - .collect(); - let exclude_keywords_refs: Vec<&str> = - exclude_keywords - .iter() - .map(|s| s.as_str()) - .collect(); - - if let Some(comment_kind) = - identify_todo_comment( - &comment.text, - Some(&include_keywords_refs), - Some(&exclude_keywords_refs), - ) - { - Some(TodoData { - path: source_file_name.clone(), - comment: comment.text.clone(), - start: comment.start, - kind: comment_kind, - end: comment.end, - }) - } else { - None - } - }) - .collect(); - - if todos.len() > 0 { - let mut counts = todo_counts.lock().await; - counts - .insert(source_file_name.clone(), todos.len()); - } - - todos - } - Err(e) => { - eprintln!( - "Error: Cannot read file {}: {:?}", - source_file_name, e - ); - vec![] - } - } - }) - }) - .collect(); - - let mut todo_data: Vec = Vec::new(); - for task in todo_data_tasks { - if let Ok(result) = task.await { - todo_data.extend(result); - } - } - - let counts = todo_counts.lock().await; - drop(counts); - - let todo_with_blame_tasks: Vec<_> = todo_data - .into_iter() - .map(|todo| { - tokio::spawn(async move { - if let Ok(source_code) = fs::read_to_string(&todo.path).await { - if let Some(line) = - get_line_from_position(todo.start, &source_code) - { - if let Some(blame_data) = blame(&todo.path, line).await - { - let prepared_blame: PreparedBlameData = - prepare_blame_data(blame_data); - return Some(TodoWithBlame { - comment: todo.comment.trim().to_string(), - path: todo.path.clone(), - blame: prepared_blame, - kind: todo.kind, - line, - }); - } - } - } - None - }) - }) - .collect(); - - let mut todos_with_blame: Vec = Vec::new(); - for task in todo_with_blame_tasks { - if let Ok(Some(todo)) = task.await { - todos_with_blame.push(todo); - } - } - - todos_with_blame.sort_by(|a, b| a.blame.date.cmp(&b.blame.date)); + let args = parse_args(&version); - let mut history: Vec<(String, String)> = get_history(Some(*months)).await; - history = remove_duplicate_dates(history); + check_git_repository_or_exit().await; - if history.len() > 1 { - history.remove(0); - } - - let history_len = history.len(); - - let progress_bar = ProgressBar::new(history_len as u64); - - let progress_style = ProgressStyle::default_bar() - .template("{bar:40.cyan/blue} {pos}/{len} ({percent}%)") - .expect("Failed to create progress bar template") - .progress_chars("▇▇ "); - progress_bar.set_style(progress_style); - - let mut previous_commit_hash: Option = get_last_commit_hash().await; - - for (_index, (commit_hash, date)) in history.iter().enumerate() { - progress_bar.inc(1); - - io::stdout().flush().unwrap(); - - let files_list = - get_files_list(Some(commit_hash.as_str())).await.unwrap(); - - let supported_files: Vec<_> = files_list - .clone() - .into_iter() - .filter(|file| { - identify_not_ignored_file(file, &ignores) - && identify_supported_file(file) - }) - .collect(); - - let modified_files = if let Some(prev_hash) = &previous_commit_hash { - get_modified_files(prev_hash, &commit_hash) - .await - .into_iter() - .filter(|file| { - identify_not_ignored_file(file, &ignores) - && identify_supported_file(file) - }) - .collect::>() - } else { - supported_files.clone() - }; - - for file_path in &modified_files { - let file_content_result = - get_file_by_commit(&commit_hash, &file_path).await; - - match file_content_result { - Ok(file_content) => { - let comments = get_comments(&file_content, file_path); - - let include_keywords_refs: Vec<&str> = - include_keywords.iter().map(|s| s.as_str()).collect(); - let exclude_keywords_refs: Vec<&str> = - exclude_keywords.iter().map(|s| s.as_str()).collect(); - - let todos: Vec<_> = comments - .into_iter() - .filter(|comment| { - identify_todo_comment( - &comment.text, - Some(&include_keywords_refs), - Some(&exclude_keywords_refs), - ) - .is_some() - }) - .collect(); + let files = get_filtered_files(&args.ignore).await; - let new_count = todos.len(); + let (todo_data, todo_counts) = collect_todo_data( + &files, + &args.include_keywords, + &args.exclude_keywords, + ) + .await; - { - let mut counts = todo_counts.lock().await; - if new_count > 0 { - counts.insert(file_path.clone(), new_count); - } else { - counts.remove(file_path); - } - } - } - Err(_) => { - let mut counts = todo_counts.lock().await; - counts.remove(file_path); - } - }; - } + let todos_with_blame = enrich_todo_data_with_blame(todo_data).await; - let counts = todo_counts.lock().await; - let total_todo_count: usize = counts.values().sum(); - drop(counts); + let todo_history_data = collect_todo_history( + args.month, + &args.ignore, + &args.include_keywords, + &args.exclude_keywords, + todo_counts, + ) + .await; - todo_history_data.push(TodoHistory { - date: date.clone(), - count: total_todo_count, - }); + let json_data = + prepare_json_data(&todo_history_data, &todos_with_blame, &version) + .await; - previous_commit_hash = Some(commit_hash.clone()); - } - - progress_bar.finish_with_message("All commits processed!"); - - todo_history_data = - add_missing_days(todo_history_data, *months, todos_with_blame.len()); - - let current_directory = get_current_directory() - .expect("Error: Could not get current directory."); - let project_name = - get_project_name().unwrap_or_else(|| "Unknown Project".to_string()); - - let json_data = json!({ - "currentPath": current_directory, - "history": todo_history_data, - "data": todos_with_blame, - "name": project_name, - "version": version, - }); - - generate_output(*output_format, output_directory, json_data).await; + generate_output(args.output_format, &args.output, json_data).await; } -async fn generate_output( - output_format: OutputFormat, - output_directory: &str, - json_data: Value, -) { - match output_format { - OutputFormat::Html => { - let dist_path: PathBuf = get_dist_path() - .expect("Error: Could not get current dist path."); +fn parse_args(version: &str) -> Cli { + use clap::FromArgMatches; - copy_dir_recursive(&dist_path, Path::new(output_directory)) - .await - .expect("Error copying directory"); - - let mut escaped_json_data = json_data.clone(); - escape_json_values(&mut escaped_json_data); - - let escaped_json_string = serde_json::to_string(&escaped_json_data) - .expect("Error: Could not serializing JSON"); - - let index_path = Path::new(output_directory).join("index.html"); - if fs::metadata(&index_path).await.is_ok() { - let mut index_content = fs::read_to_string(&index_path) - .await - .expect("Error reading index.html"); - - if let Some(pos) = index_content.find("") { - let script_tag = format!( - "", - escaped_json_string - ); - index_content.insert_str(pos, &script_tag); - - fs::write(&index_path, index_content) - .await - .expect("Error writing modified index.html"); - } else { - eprintln!("Error: No tag found in index.html"); - } + let version_static: &'static str = + Box::leak(version.to_string().into_boxed_str()); - if let Err(e) = open::that(&index_path) { - eprintln!("Error: Cannot open index.html: {:?}", e); - } - } - } - OutputFormat::Json => { - let json_path = Path::new(output_directory).join("report.json"); - let mut file = File::create(&json_path) - .expect("Failed to create JSON report file"); - let formatted_json = serde_json::to_string_pretty(&json_data) - .expect("Failed to format JSON data"); + let mut cmd = Cli::command(); + cmd = cmd.version(version_static); + let matches = cmd.get_matches(); - file.write_all(formatted_json.as_bytes()) - .expect("Failed to write JSON data"); - } + match Cli::from_arg_matches(&matches) { + Ok(cli) => cli, + Err(e) => e.exit(), } } diff --git a/src/project/check_git_repository_or_exit.rs b/src/project/check_git_repository_or_exit.rs new file mode 100644 index 0000000..0f8bd0d --- /dev/null +++ b/src/project/check_git_repository_or_exit.rs @@ -0,0 +1,9 @@ +use crate::git::check_git_repository; +use std::process; + +pub async fn check_git_repository_or_exit() { + if !check_git_repository(".").await { + eprintln!("Error: This is not a Git repository."); + process::exit(1); + } +} diff --git a/src/project/collect_todo_data.rs b/src/project/collect_todo_data.rs new file mode 100644 index 0000000..f047310 --- /dev/null +++ b/src/project/collect_todo_data.rs @@ -0,0 +1,90 @@ +use crate::comments::{get_comments, identify_todo_comment}; +use crate::types::TodoData; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::fs; +use tokio::sync::Mutex; + +pub async fn collect_todo_data( + files: &[String], + include_keywords: &[String], + exclude_keywords: &[String], +) -> (Vec, Arc>>) { + let todo_counts = Arc::new(Mutex::new(HashMap::new())); + let todo_counts_clone = Arc::clone(&todo_counts); + + let todo_data_tasks: Vec<_> = files + .iter() + .cloned() + .map(|source_file_name| { + let include_keywords = include_keywords.to_vec(); + let exclude_keywords = exclude_keywords.to_vec(); + let todo_counts = Arc::clone(&todo_counts_clone); + + tokio::spawn(async move { + match fs::read_to_string(&source_file_name).await { + Ok(source) => { + let comments = get_comments(&source, &source_file_name); + let todos: Vec = comments + .into_iter() + .filter_map(|comment| { + let include_keywords_refs: Vec<&str> = + include_keywords + .iter() + .map(|s| s.as_str()) + .collect(); + let exclude_keywords_refs: Vec<&str> = + exclude_keywords + .iter() + .map(|s| s.as_str()) + .collect(); + + if let Some(comment_kind) = + identify_todo_comment( + &comment.text, + Some(&include_keywords_refs), + Some(&exclude_keywords_refs), + ) + { + Some(TodoData { + path: source_file_name.clone(), + comment: comment.text.clone(), + start: comment.start, + kind: comment_kind, + end: comment.end, + }) + } else { + None + } + }) + .collect(); + + if !todos.is_empty() { + let mut counts = todo_counts.lock().await; + counts + .insert(source_file_name.clone(), todos.len()); + } + + todos + } + Err(e) => { + eprintln!( + "Error: Cannot read file {}: {:?}", + source_file_name, e + ); + vec![] + } + } + }) + }) + .collect(); + + let mut todo_data: Vec = Vec::new(); + for task in todo_data_tasks { + if let Ok(result) = task.await { + todo_data.extend(result); + } + } + + (todo_data, todo_counts_clone) +} diff --git a/src/project/collect_todo_history.rs b/src/project/collect_todo_history.rs new file mode 100644 index 0000000..504241a --- /dev/null +++ b/src/project/collect_todo_history.rs @@ -0,0 +1,141 @@ +use crate::comments::{ + get_comments, identify_not_ignored_file, identify_supported_file, + identify_todo_comment, +}; +use crate::git::{ + get_file_by_commit, get_files_list, get_history, get_last_commit_hash, + get_modified_files, +}; +use crate::types::TodoHistory; +use crate::utils::{add_missing_days, remove_duplicate_dates}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::collections::HashMap; +use std::io::{self, Write}; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub async fn collect_todo_history( + months: u32, + ignores: &[String], + include_keywords: &[String], + exclude_keywords: &[String], + todo_counts: Arc>>, +) -> Vec { + let mut todo_history_data: Vec = Vec::new(); + + let mut history: Vec<(String, String)> = get_history(Some(months)).await; + history = remove_duplicate_dates(history); + + if history.len() > 1 { + history.remove(0); + } + + let history_len = history.len(); + + let progress_bar = ProgressBar::new(history_len as u64); + + let progress_style = ProgressStyle::default_bar() + .template("{bar:40.cyan/blue} {pos}/{len} ({percent}%)") + .expect("Failed to create progress bar template") + .progress_chars("▇▇ "); + progress_bar.set_style(progress_style); + + let mut previous_commit_hash: Option = get_last_commit_hash().await; + + for (_index, (commit_hash, date)) in history.iter().enumerate() { + progress_bar.inc(1); + + io::stdout().flush().unwrap(); + + let files_list = + get_files_list(Some(commit_hash.as_str())).await.unwrap(); + + let supported_files: Vec<_> = files_list + .clone() + .into_iter() + .filter(|file| { + identify_not_ignored_file(file, ignores) + && identify_supported_file(file) + }) + .collect(); + + let modified_files = if let Some(prev_hash) = &previous_commit_hash { + get_modified_files(prev_hash, commit_hash) + .await + .into_iter() + .filter(|file| { + identify_not_ignored_file(file, ignores) + && identify_supported_file(file) + }) + .collect::>() + } else { + supported_files.clone() + }; + + for file_path in &modified_files { + let file_content_result = + get_file_by_commit(commit_hash, file_path).await; + + match file_content_result { + Ok(file_content) => { + let comments = get_comments(&file_content, file_path); + + let include_keywords_refs: Vec<&str> = + include_keywords.iter().map(|s| s.as_str()).collect(); + let exclude_keywords_refs: Vec<&str> = + exclude_keywords.iter().map(|s| s.as_str()).collect(); + + let todos: Vec<_> = comments + .into_iter() + .filter(|comment| { + identify_todo_comment( + &comment.text, + Some(&include_keywords_refs), + Some(&exclude_keywords_refs), + ) + .is_some() + }) + .collect(); + + let new_count = todos.len(); + + { + let mut counts = todo_counts.lock().await; + if new_count > 0 { + counts.insert(file_path.clone(), new_count); + } else { + counts.remove(file_path); + } + } + } + Err(_) => { + let mut counts = todo_counts.lock().await; + counts.remove(file_path); + } + }; + } + + let counts = todo_counts.lock().await; + let total_todo_count: usize = counts.values().sum(); + drop(counts); + + todo_history_data.push(TodoHistory { + date: date.clone(), + count: total_todo_count, + }); + + previous_commit_hash = Some(commit_hash.clone()); + } + + progress_bar.finish_with_message("All commits processed!"); + + let current_total = { + let counts = todo_counts.lock().await; + counts.values().sum() + }; + + todo_history_data = + add_missing_days(todo_history_data, months, current_total); + + todo_history_data +} diff --git a/src/project/enrich_todo_data_with_blame.rs b/src/project/enrich_todo_data_with_blame.rs new file mode 100644 index 0000000..3782212 --- /dev/null +++ b/src/project/enrich_todo_data_with_blame.rs @@ -0,0 +1,49 @@ +use crate::comments::prepare_blame_data::{ + prepare_blame_data, PreparedBlameData, +}; +use crate::git::{get_blame_data, get_line_from_position}; +use crate::types::{TodoData, TodoWithBlame}; +use tokio::fs; + +pub async fn enrich_todo_data_with_blame( + todo_data: Vec, +) -> Vec { + let todo_with_blame_tasks: Vec<_> = todo_data + .into_iter() + .map(|todo| { + tokio::spawn(async move { + if let Ok(source_code) = fs::read_to_string(&todo.path).await { + if let Some(line) = + get_line_from_position(todo.start, &source_code) + { + if let Some(blame_data) = + get_blame_data(&todo.path, line).await + { + let prepared_blame: PreparedBlameData = + prepare_blame_data(blame_data); + return Some(TodoWithBlame { + comment: todo.comment.trim().to_string(), + path: todo.path.clone(), + blame: prepared_blame, + kind: todo.kind, + line, + }); + } + } + } + None + }) + }) + .collect(); + + let mut todos_with_blame: Vec = Vec::new(); + for task in todo_with_blame_tasks { + if let Ok(Some(todo)) = task.await { + todos_with_blame.push(todo); + } + } + + todos_with_blame.sort_by(|a, b| a.blame.date.cmp(&b.blame.date)); + + todos_with_blame +} diff --git a/src/project/generate_output.rs b/src/project/generate_output.rs new file mode 100644 index 0000000..330aabf --- /dev/null +++ b/src/project/generate_output.rs @@ -0,0 +1,67 @@ +use crate::fs::{copy_dir_recursive, get_dist_path}; +use crate::types::OutputFormat; +use crate::utils::escape_json_values; +use open; +use serde_json::Value; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use tokio::fs; + +pub async fn generate_output( + output_format: OutputFormat, + output_directory: &str, + json_data: Value, +) { + match output_format { + OutputFormat::Html => { + let dist_path: PathBuf = get_dist_path() + .expect("Error: Could not get current dist path."); + + copy_dir_recursive(&dist_path, Path::new(output_directory)) + .await + .expect("Error copying directory"); + + let mut escaped_json_data = json_data.clone(); + escape_json_values(&mut escaped_json_data); + + let escaped_json_string = serde_json::to_string(&escaped_json_data) + .expect("Error: Could not serializing JSON"); + + let index_path = Path::new(output_directory).join("index.html"); + if fs::metadata(&index_path).await.is_ok() { + let mut index_content = fs::read_to_string(&index_path) + .await + .expect("Error reading index.html"); + + if let Some(pos) = index_content.find("") { + let script_tag = format!( + "", + escaped_json_string + ); + index_content.insert_str(pos, &script_tag); + + fs::write(&index_path, index_content) + .await + .expect("Error writing modified index.html"); + } else { + eprintln!("Error: No tag found in index.html"); + } + + if let Err(e) = open::that(&index_path) { + eprintln!("Error: Cannot open index.html: {:?}", e); + } + } + } + OutputFormat::Json => { + let json_path = Path::new(output_directory).join("report.json"); + let mut file = File::create(&json_path) + .expect("Failed to create JSON report file"); + let formatted_json = serde_json::to_string_pretty(&json_data) + .expect("Failed to format JSON data"); + + file.write_all(formatted_json.as_bytes()) + .expect("Failed to write JSON data"); + } + } +} diff --git a/src/project/get_filtered_files.rs b/src/project/get_filtered_files.rs new file mode 100644 index 0000000..4321b93 --- /dev/null +++ b/src/project/get_filtered_files.rs @@ -0,0 +1,14 @@ +use crate::comments::{identify_not_ignored_file, identify_supported_file}; +use crate::git::get_files_list; + +pub async fn get_filtered_files(ignores: &[String]) -> Vec { + let files_list = get_files_list(None).await.unwrap(); + + files_list + .into_iter() + .filter(|file| { + identify_not_ignored_file(file, ignores) + && identify_supported_file(file) + }) + .collect() +} diff --git a/src/get_project_name.rs b/src/project/get_project_name.rs similarity index 81% rename from src/get_project_name.rs rename to src/project/get_project_name.rs index dab6ef4..807a8cd 100644 --- a/src/get_project_name.rs +++ b/src/project/get_project_name.rs @@ -1,4 +1,4 @@ -use crate::get_current_directory::get_current_directory; +use crate::fs::get_current_directory; pub fn get_project_name() -> Option { if let Some(current_dir) = get_current_directory() { diff --git a/src/get_todoctor_version.rs b/src/project/get_todoctor_version.rs similarity index 96% rename from src/get_todoctor_version.rs rename to src/project/get_todoctor_version.rs index 7cbe233..ff64dcb 100644 --- a/src/get_todoctor_version.rs +++ b/src/project/get_todoctor_version.rs @@ -1,4 +1,4 @@ -use crate::get_dist_path::get_dist_path; +use crate::fs::get_dist_path; use serde_json::Value; use tokio::fs; diff --git a/src/project/mod.rs b/src/project/mod.rs new file mode 100644 index 0000000..056e0ac --- /dev/null +++ b/src/project/mod.rs @@ -0,0 +1,19 @@ +pub use self::check_git_repository_or_exit::check_git_repository_or_exit; +pub use self::collect_todo_data::collect_todo_data; +pub use self::collect_todo_history::collect_todo_history; +pub use self::enrich_todo_data_with_blame::enrich_todo_data_with_blame; +pub use self::generate_output::generate_output; +pub use self::get_filtered_files::get_filtered_files; +pub use self::get_project_name::get_project_name; +pub use self::get_todoctor_version::get_todoctor_version; +pub use self::prepare_json_data::prepare_json_data; + +pub mod check_git_repository_or_exit; +pub mod collect_todo_data; +pub mod collect_todo_history; +pub mod enrich_todo_data_with_blame; +pub mod generate_output; +pub mod get_filtered_files; +pub mod get_project_name; +pub mod get_todoctor_version; +pub mod prepare_json_data; diff --git a/src/project/prepare_json_data.rs b/src/project/prepare_json_data.rs new file mode 100644 index 0000000..b905a62 --- /dev/null +++ b/src/project/prepare_json_data.rs @@ -0,0 +1,23 @@ +use crate::fs::get_current_directory; +use crate::project::get_project_name; +use crate::types::{TodoHistory, TodoWithBlame}; +use serde_json::json; + +pub async fn prepare_json_data( + todo_history_data: &[TodoHistory], + todos_with_blame: &[TodoWithBlame], + version: &str, +) -> serde_json::Value { + let current_directory = get_current_directory() + .expect("Error: Could not get current directory."); + let project_name = + get_project_name().unwrap_or_else(|| "Unknown Project".to_string()); + + json!({ + "currentPath": current_directory, + "history": todo_history_data, + "data": todos_with_blame, + "name": project_name, + "version": version, + }) +} diff --git a/src/types.rs b/src/types.rs index 86af669..8835819 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,6 +1,13 @@ -use crate::prepare_blame_data::PreparedBlameData; +use crate::comments::prepare_blame_data::PreparedBlameData; +use clap::ValueEnum; use serde::{Deserialize, Serialize}; +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] +pub enum OutputFormat { + Html, + Json, +} + #[derive(Debug, Serialize)] pub struct TodoData { pub comment: String, diff --git a/src/add_missing_days.rs b/src/utils/add_missing_days.rs similarity index 100% rename from src/add_missing_days.rs rename to src/utils/add_missing_days.rs diff --git a/src/escape_html.rs b/src/utils/escape_html.rs similarity index 100% rename from src/escape_html.rs rename to src/utils/escape_html.rs diff --git a/src/escape_json_values.rs b/src/utils/escape_json_values.rs similarity index 93% rename from src/escape_json_values.rs rename to src/utils/escape_json_values.rs index f3bdc0a..f1adcf1 100644 --- a/src/escape_json_values.rs +++ b/src/utils/escape_json_values.rs @@ -1,4 +1,4 @@ -use crate::escape_html::escape_html; +use crate::utils::escape_html; use serde_json::Value; pub fn escape_json_values(json_value: &mut Value) { diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..22c740b --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,9 @@ +pub use self::add_missing_days::add_missing_days; +pub use self::escape_html::escape_html; +pub use self::escape_json_values::escape_json_values; +pub use self::remove_duplicate_dates::remove_duplicate_dates; + +pub mod add_missing_days; +pub mod escape_html; +pub mod escape_json_values; +pub mod remove_duplicate_dates; diff --git a/src/remove_duplicate_dates.rs b/src/utils/remove_duplicate_dates.rs similarity index 100% rename from src/remove_duplicate_dates.rs rename to src/utils/remove_duplicate_dates.rs diff --git a/tests/check_git_repository.rs b/tests/check_git_repository.rs index 09219a8..746917f 100644 --- a/tests/check_git_repository.rs +++ b/tests/check_git_repository.rs @@ -1,5 +1,5 @@ use std::path::Path; -use todoctor::check_git_repository::check_git_repository; +use todoctor::git::check_git_repository; #[tokio::test] async fn test_is_git_repository_true() { diff --git a/tests/identify_todo_comment.rs b/tests/identify_todo_comment.rs index 7a9a565..d35d097 100644 --- a/tests/identify_todo_comment.rs +++ b/tests/identify_todo_comment.rs @@ -1,4 +1,4 @@ -use todoctor::identify_todo_comment::identify_todo_comment; +use todoctor::comments::identify_todo_comment; #[tokio::test] async fn test_primary_keyword_found() {