use std::{io::Write as _, path::PathBuf, sync::LazyLock, time::SystemTime};
use chrono::{DateTime, Local};
use clap::Parser;
use crossterm::{
QueueableCommand as _,
style::{Color, Print, ResetColor, SetForegroundColor},
};
use todl::{
SearchOptions, Tag, search_files,
tag::{TagKind, TagLevel},
};
use unicode_segmentation::UnicodeSegmentation as _;
#[derive(Debug, Parser)]
#[command(version, about)]
struct Args {
paths: Vec<PathBuf>,
#[arg(short, long, default_values = ["fix", "improvement"])]
levels: Vec<TagLevel>,
#[arg(short, long)]
tag: Option<TagKind>,
#[arg(short = 'i', long, default_value_t = false)]
no_ignore: bool,
#[arg(short = 'b', long, default_value_t = false)]
no_blame: bool,
#[arg(long, default_value_t = false)]
no_count: bool,
#[arg(short, long, default_value_t = false)]
sort: bool,
#[arg(short, long, default_value_t = false)]
reverse: bool,
#[arg(short, long, default_value_t = false)]
json: bool,
}
static STDOUT_ATTY: LazyLock<bool> = LazyLock::new(|| atty::is(atty::Stream::Stdout));
static TERMINAL_WIDTH: LazyLock<usize> = LazyLock::new(|| {
crossterm::terminal::size()
.map(|s| s.0 as usize)
.unwrap_or(120)
});
macro_rules! color_print {
($color:expr, $($arg:tt)*) => {
do_colour_print($color, format_args!($($arg)*))
};
}
fn do_colour_print(color: Color, args: std::fmt::Arguments) {
if *STDOUT_ATTY && inner_colour_print(color, args).is_ok() {
return;
}
print!("{args}");
}
fn inner_colour_print(color: Color, args: std::fmt::Arguments) -> crossterm::Result<()> {
std::io::stdout()
.queue(SetForegroundColor(color))?
.queue(Print(args))?
.queue(ResetColor)?
.flush()?;
Ok(())
}
fn main() {
let args = Args::parse();
let paths = if args.paths.is_empty() {
vec![PathBuf::from(".")]
} else {
args.paths
};
let search_options = SearchOptions {
git_ignore: !args.no_ignore,
git_blame: !args.no_blame,
};
let mut tags: Box<dyn Iterator<Item = Tag>> = Box::new(
paths
.iter()
.flat_map(|path| search_files(path, search_options))
.filter(|tag| args.levels.contains(&tag.kind.level()))
.filter(|tag| {
let Some(tag_filter) = &args.tag else {
return true;
};
tag_filter == &tag.kind
}),
);
if args.sort {
let mut tag_vec: Vec<Tag> = tags.collect();
tag_vec.sort_by(|a, b| {
let ordering = b.git_info.cmp(&a.git_info);
if args.reverse {
ordering.reverse()
} else {
ordering
}
});
tags = Box::new(tag_vec.into_iter());
}
if args.json {
let tags_vec: Vec<Tag> = tags.collect();
println!(
"{}",
serde_json::ser::to_string_pretty(&tags_vec).expect("could not serialize to json")
);
return;
}
let tags = tags.inspect(print_tag);
if !args.no_count {
let count = tags.count();
println!();
println!("Found {count} results");
}
}
fn print_tag(tag: &Tag) {
let min_tag_length = 9;
let tag_kind = tag.kind.to_string();
color_print!(tag.kind.color(), "{:min_tag_length$} ", tag_kind);
let tag_kind_length = tag_kind.graphemes(true).count().max(min_tag_length) + 1;
let path_length = format_path_line(tag).graphemes(true).count() + 1;
let git_length = tag
.git_info
.as_ref()
.map(|g| {
format!("{} {}", format_system_time(g.time), g.author)
.graphemes(true)
.count()
})
.unwrap_or(0);
let length = *TERMINAL_WIDTH - 2 - tag_kind_length - path_length - git_length;
let msg = tag
.message
.graphemes(true)
.chain(std::iter::once(" ").cycle())
.take(length)
.collect::<String>();
debug_assert_eq!(msg.graphemes(true).count(), length);
color_print!(Color::White, "{}", msg);
color_print!(Color::Yellow, "{} ", format_path_line(tag));
if let Some(git_info) = &tag.git_info {
color_print!(Color::Blue, "{} ", format_system_time(git_info.time));
color_print!(Color::Green, "{}", git_info.author);
}
println!();
}
fn format_system_time(time: SystemTime) -> impl std::fmt::Display {
let time: DateTime<Local> = time.into();
time.format("%F %T")
}
fn format_path_line(tag: &Tag) -> String {
format!("{}:{}", tag.path.display(), tag.line)
}