use clap::Parser;
use csv::Writer;
use git2::build::CheckoutBuilder;
use git2::{Commit, DiffOptions, Error, Object, ObjectType, Repository, Status};
use gossiphs::api::RelatedFileContext;
use gossiphs::graph::{Graph, GraphConfig};
use gossiphs::server::{server_main, ServerConfig};
use indicatif::ProgressBar;
use inquire::Text;
use rayon::iter::ParallelIterator;
use rayon::prelude::IntoParallelRefIterator;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use termtree::Tree;
use tracing::{debug, info};
#[derive(Parser, Debug)]
#[clap(
name = "gossiphs",
bin_name = "gossiphs",
version = env!("CARGO_PKG_VERSION"),
about = "gossiphs (gossip-graphs) command line tool",
)]
struct Cli {
#[clap(subcommand)]
cmd: SubCommand,
}
#[derive(Parser, Debug)]
enum SubCommand {
#[clap(name = "relate")]
Relate(RelateCommand),
#[clap(name = "relation")]
Relation(RelationCommand),
#[clap(name = "relation2")]
Relation2(RelationCommand),
#[clap(name = "interactive")]
Interactive(InteractiveCommand),
#[clap(name = "server")]
Server(ServerCommand),
#[clap(name = "obsidian")]
Obsidian(ObsidianCommand),
#[clap(name = "diff")]
Diff(DiffCommand),
}
#[derive(Parser, Debug)]
struct CommonOptions {
#[clap(short, long)]
#[clap(default_value = ".")]
project_path: String,
#[clap(long)]
#[clap(default_value = "false")]
strict: bool,
#[clap(long)]
def_limit: Option<usize>,
#[clap(long)]
depth: Option<u32>,
#[clap(long)]
exclude_file_regex: Option<String>,
#[clap(long)]
exclude_author_regex: Option<String>,
#[clap(long)]
symbol_len_limit: Option<usize>,
}
impl CommonOptions {
#[cfg(test)]
fn default() -> CommonOptions {
CommonOptions {
project_path: String::from("."),
strict: false,
def_limit: None,
depth: None,
exclude_file_regex: None,
exclude_author_regex: None,
symbol_len_limit: None,
}
}
}
#[derive(Parser, Debug)]
struct RelateCommand {
#[clap(flatten)]
common_options: CommonOptions,
#[clap(long)]
#[clap(default_value = "")]
file: String,
#[clap(long)]
#[clap(default_value = "")]
file_txt: String,
#[clap(long)]
#[clap(default_value = None)]
json: Option<String>,
#[clap(long)]
#[clap(default_value = "true")]
ignore_zero: bool,
}
#[derive(Parser, Debug)]
struct RelationCommand {
#[clap(flatten)]
common_options: CommonOptions,
#[clap(long)]
#[clap(default_value = "output.csv")]
csv: String,
#[clap(long)]
#[clap(default_value = "")]
symbol_csv: String,
#[clap(long)]
#[clap(default_value = "output.index")]
index_file: String,
}
#[derive(Parser, Debug)]
struct InteractiveCommand {
#[clap(flatten)]
common_options: CommonOptions,
#[clap(long)]
#[clap(default_value = "false")]
dry: bool,
}
#[derive(Parser, Debug)]
struct ServerCommand {
#[clap(flatten)]
common_options: CommonOptions,
#[clap(long)]
#[clap(default_value = "9411")]
port: u16,
}
#[derive(Parser, Debug)]
struct ObsidianCommand {
#[clap(flatten)]
common_options: CommonOptions,
#[clap(long)]
vault_dir: String,
}
#[derive(Parser, Debug)]
struct DiffCommand {
#[clap(flatten)]
common_options: CommonOptions,
#[clap(long)]
#[clap(default_value = "HEAD~1")]
target: String,
#[clap(long)]
#[clap(default_value = "HEAD")]
source: String,
#[clap(long)]
#[clap(default_value = "false")]
json: bool,
}
impl RelateCommand {
pub fn get_files(&self) -> Vec<String> {
if !self.file_txt.is_empty() {
let file_contents = match fs::read_to_string(&self.file_txt) {
Ok(contents) => contents,
Err(err) => {
eprintln!("Error reading file {}: {}", self.file_txt, err);
return Vec::new();
}
};
return file_contents
.clone()
.lines()
.filter(|each| !each.trim().is_empty())
.map(|each| each.to_string())
.collect();
}
self.file.split(';').map(|each| each.to_string()).collect()
}
}
fn main() {
let cli: Cli = Cli::parse();
match cli.cmd {
SubCommand::Relate(search_cmd) => handle_relate(search_cmd),
SubCommand::Relation(relation_cmd) => handle_relation(relation_cmd),
SubCommand::Relation2(relation_cmd) => handle_relation_v2(relation_cmd),
SubCommand::Interactive(interactive_cmd) => handle_interactive(interactive_cmd),
SubCommand::Server(server_cmd) => handle_server(server_cmd),
SubCommand::Obsidian(obsidian_cmd) => handle_obsidian(obsidian_cmd),
SubCommand::Diff(diff_cmd) => handle_diff(diff_cmd),
}
}
fn handle_relate(relate_cmd: RelateCommand) {
if !relate_cmd.json.is_none() {
tracing_subscriber::fmt::init();
}
let mut config = GraphConfig::default();
config.project_path = relate_cmd.common_options.project_path.clone();
if relate_cmd.common_options.strict {
config.def_limit = 1
}
if !relate_cmd.common_options.depth.is_none() {
config.depth = relate_cmd.common_options.depth.unwrap();
}
let g = Graph::from(config);
let mut related_files_data = Vec::new();
let files = relate_cmd.get_files();
for file in &files {
let mut files = g.related_files(String::from(file));
if relate_cmd.ignore_zero {
files.retain(|each| each.score > 0);
}
related_files_data.push(RelatedFileWrapper {
name: file.to_string(),
related: files,
});
}
let json = serde_json::to_string(&related_files_data).unwrap();
if !relate_cmd.json.is_none() {
fs::write(relate_cmd.json.unwrap(), json).expect("");
} else {
println!("{}", json);
}
}
fn handle_relation_v2(relation_cmd: RelationCommand) {
let mut config = GraphConfig::default();
config.project_path = relation_cmd.common_options.project_path.clone();
if relation_cmd.common_options.strict {
config.def_limit = 1;
}
if relation_cmd.common_options.def_limit.is_some() {
config.def_limit = relation_cmd.common_options.def_limit.unwrap();
}
if let Some(depth) = relation_cmd.common_options.depth {
config.depth = depth;
}
if let Some(exclude) = relation_cmd.common_options.exclude_file_regex {
config.exclude_file_regex = exclude;
}
config.exclude_author_regex = relation_cmd.common_options.exclude_author_regex.clone();
let g = Graph::from(config);
let relation_list = g.list_all_relations();
let mut writer =
BufWriter::new(File::create(relation_cmd.index_file).expect("Unable to create file"));
for node in relation_list.file_nodes {
let serialized = serde_json::to_string(&node).expect("Failed to serialize FileNode");
writeln!(writer, "{}", serialized).expect("Unable to write data");
}
for relation in relation_list.file_relations {
let serialized =
serde_json::to_string(&relation).expect("Failed to serialize FileRelation");
writeln!(writer, "{}", serialized).expect("Unable to write data");
}
for node in relation_list.symbol_nodes {
let serialized = serde_json::to_string(&node).expect("Failed to serialize SymbolNode");
writeln!(writer, "{}", serialized).expect("Unable to write data");
}
}
fn handle_relation(relation_cmd: RelationCommand) {
let mut config = GraphConfig::default();
config.project_path = relation_cmd.common_options.project_path.clone();
if relation_cmd.common_options.strict {
config.def_limit = 1;
}
if relation_cmd.common_options.def_limit.is_some() {
config.def_limit = relation_cmd.common_options.def_limit.unwrap();
}
if let Some(depth) = relation_cmd.common_options.depth {
config.depth = depth;
}
if let Some(exclude) = relation_cmd.common_options.exclude_file_regex {
config.exclude_file_regex = exclude;
}
config.exclude_author_regex = relation_cmd.common_options.exclude_author_regex.clone();
if let Some(symbol_len_limit) = relation_cmd.common_options.symbol_len_limit {
config.symbol_len_limit = symbol_len_limit;
}
let g = Graph::from(config);
let mut files: Vec<String> = g.files().into_iter().collect();
files.sort();
let wtr_result = Writer::from_path(relation_cmd.csv);
let mut wtr = match wtr_result {
Ok(writer) => writer,
Err(e) => panic!("Failed to create CSV writer: {}", e),
};
let mut header = vec!["".to_string()];
header.extend(files.clone());
if let Err(e) = wtr.write_record(&header) {
panic!("Failed to write CSV header: {}", e);
}
let mut symbol_wtr_opts = None;
if !relation_cmd.symbol_csv.is_empty() {
let symbol_wtr_result = Writer::from_path(relation_cmd.symbol_csv);
symbol_wtr_opts = match symbol_wtr_result {
Ok(writer) => Some(writer),
Err(e) => panic!("Failed to create CSV writer: {}", e),
};
let mut header = vec!["".to_string()];
header.extend(files.clone());
if let Some(symbol_wtr) = symbol_wtr_opts.as_mut() {
symbol_wtr
.write_record(&header)
.expect("Failed to write header to symbol_wtr");
}
}
let pb = ProgressBar::new(files.len() as u64);
let results: HashMap<String, (Vec<String>, Vec<String>)> = files
.par_iter()
.map(|file| {
pb.inc(1);
let mut row = vec![file.clone()];
let mut pair_row = vec![file.clone()];
let related_files_map: HashMap<_, _> = g
.related_files(file.clone())
.into_iter()
.map(|rf| (rf.name, rf.score))
.collect();
for related_file in &files {
if let Some(score) = related_files_map.get(related_file) {
if *score > 0 {
row.push(score.to_string());
if symbol_wtr_opts.is_some() {
let pairs = g
.pairs_between_files(file.clone(), related_file.clone())
.iter()
.map(|each| each.src_symbol.name.clone())
.collect::<Vec<String>>();
pair_row.push(pairs.join("|"));
}
} else {
row.push(String::new());
pair_row.push(String::new());
}
} else {
row.push(String::new());
pair_row.push(String::new());
}
}
(file.clone(), (row, pair_row))
})
.collect();
pb.finish_and_clear();
let sorted_results: Vec<(Vec<String>, Vec<String>)> = files
.iter()
.map(|file| results.get(file).unwrap().clone())
.collect();
for (row, pair_row) in sorted_results {
wtr.write_record(&row).expect("Failed to write record");
if let Some(symbol_wtr) = symbol_wtr_opts.as_mut() {
symbol_wtr
.write_record(&pair_row)
.expect("Failed to write pair_row to symbol_wtr");
}
}
if let Err(e) = wtr.flush() {
panic!("Failed to flush CSV writer: {}", e);
}
}
fn handle_interactive(interactive_cmd: InteractiveCommand) {
let mut config = GraphConfig::default();
config.project_path = interactive_cmd.common_options.project_path.clone();
if interactive_cmd.common_options.strict {
config.def_limit = 1
}
if !interactive_cmd.common_options.depth.is_none() {
config.depth = interactive_cmd.common_options.depth.unwrap();
}
let g = Graph::from(config);
if interactive_cmd.dry {
return;
}
loop {
let file_path_result = Text::new("File Path:").prompt();
match file_path_result {
Ok(name) => {
let files = g.related_files(name.clone());
let json = serde_json::to_string_pretty(&RelatedFileWrapper {
name,
related: files,
})
.unwrap();
println!("{}", json);
}
Err(_) => break,
}
}
}
#[derive(Serialize, Deserialize)]
struct RelatedFileWrapper {
pub name: String,
pub related: Vec<RelatedFileContext>,
}
fn handle_server(server_cmd: ServerCommand) {
tracing_subscriber::fmt::init();
let mut config = GraphConfig::default();
config.project_path = server_cmd.common_options.project_path.clone();
if server_cmd.common_options.strict {
config.def_limit = 1
}
if !server_cmd.common_options.depth.is_none() {
config.depth = server_cmd.common_options.depth.unwrap();
}
let g = Graph::from(config);
let mut server_config = ServerConfig::new(g);
server_config.port = server_cmd.port.clone();
info!("server up, port: {}", server_config.port);
server_main(server_config);
}
fn handle_obsidian(obsidian_cmd: ObsidianCommand) {
tracing_subscriber::fmt::init();
let mut config = GraphConfig::default();
config.project_path = obsidian_cmd.common_options.project_path.clone();
if obsidian_cmd.common_options.strict {
config.def_limit = 1
}
if !obsidian_cmd.common_options.depth.is_none() {
config.depth = obsidian_cmd.common_options.depth.unwrap();
}
let g = Graph::from(config);
let files = g.files();
match fs::create_dir(&obsidian_cmd.vault_dir) {
Ok(_) => debug!("Directory created successfully."),
Err(e) => panic!("Error creating directory: {}", e),
}
for each_file in files {
let related = g.related_files(each_file.clone());
let markdown_filename = format!("{}/{}.md", &obsidian_cmd.vault_dir, each_file);
let mut markdown_content = String::new();
for related_file in related {
markdown_content.push_str(&format!("[[{}]]\n", related_file.name));
}
let path = Path::new(&markdown_filename);
let parent = path.parent().unwrap_or_else(|| Path::new("."));
if let Err(why) = fs::create_dir_all(parent) {
panic!("couldn't create directory {}: {}", parent.display(), why);
}
let mut file = match File::create(&markdown_filename) {
Err(why) => panic!("couldn't create {}: {}", markdown_filename, why),
Ok(file) => file,
};
match file.write_all(markdown_content.as_bytes()) {
Err(why) => panic!("couldn't write to {}: {}", markdown_filename, why),
Ok(_) => debug!("Successfully wrote to {}", markdown_filename),
}
}
}
#[derive(Serialize, Deserialize)]
struct DiffFileContext {
name: String,
added: Vec<RelatedFileContext>,
deleted: Vec<RelatedFileContext>,
modified: Vec<RelatedFileContext>,
}
fn is_working_directory_clean(repo: &Repository) -> bool {
match repo.statuses(None) {
Ok(statuses) => {
for entry in statuses.iter() {
let status = entry.status();
if status.contains(Status::WT_NEW)
|| status.contains(Status::WT_MODIFIED)
|| status.contains(Status::WT_DELETED)
|| status.contains(Status::WT_TYPECHANGE)
|| status.contains(Status::WT_RENAMED)
|| status.contains(Status::INDEX_NEW)
|| status.contains(Status::INDEX_MODIFIED)
|| status.contains(Status::INDEX_DELETED)
|| status.contains(Status::INDEX_TYPECHANGE)
|| status.contains(Status::INDEX_RENAMED)
{
return false;
}
}
true
}
Err(_) => false,
}
}
fn get_current_branch(repo: &Repository) -> Option<String> {
let head = repo.head().ok()?;
let shorthand = head.shorthand()?;
Some(shorthand.to_string())
}
fn get_commit_and_object<'repo>(
repo: &'repo Repository,
rev: &str,
) -> Result<(Commit<'repo>, Object<'repo>), Error> {
let obj = repo.revparse_single(rev)?;
let commit = if obj.kind() == Some(ObjectType::Commit) {
obj.as_commit()
.map(|commit| (commit.clone(), obj.clone()))
.ok_or_else(|| Error::from_str("Object is not a commit"))
} else {
let peeled_obj = obj.peel(ObjectType::Commit)?;
peeled_obj
.as_commit()
.map(|commit| (commit.clone(), peeled_obj.clone()))
.ok_or_else(|| Error::from_str("Object could not be peeled to commit"))
};
commit
}
fn handle_diff(diff_cmd: DiffCommand) {
let project_path = diff_cmd.common_options.project_path;
let repo = Repository::open(&project_path).unwrap();
if !is_working_directory_clean(&repo) {
println!("Working directory is dirty. Commit or stash changes first.");
return;
}
let current_branch = get_current_branch(&repo);
let (target_commit, target_object) = get_commit_and_object(&repo, &diff_cmd.target).unwrap();
let (source_commit, source_object) = get_commit_and_object(&repo, &diff_cmd.source).unwrap();
let mut builder = CheckoutBuilder::new();
builder.force();
repo.checkout_tree(&target_object, Some(&mut builder))
.unwrap();
repo.set_head_detached(target_commit.id()).unwrap();
let mut config = GraphConfig::default();
config.project_path = project_path;
if diff_cmd.common_options.strict {
config.def_limit = 1
}
if !diff_cmd.common_options.depth.is_none() {
config.depth = diff_cmd.common_options.depth.unwrap();
}
let target_graph = Graph::from(config.clone());
repo.checkout_tree(&source_object, Some(&mut builder))
.unwrap();
repo.set_head_detached(source_commit.id()).unwrap();
if !current_branch.is_none() {
let current_branch_str = current_branch.unwrap();
if let Err(e) = repo.set_head(&format!("refs/heads/{}", current_branch_str)) {
eprintln!(
"Failed to switch back to branch '{}': {}",
current_branch_str, e
);
}
}
let source_graph = Graph::from(config);
let mut diff_options = DiffOptions::new();
let diff = repo
.diff_tree_to_tree(
Some(&target_commit.tree().unwrap()),
Some(&source_commit.tree().unwrap()),
Some(&mut diff_options),
)
.unwrap();
let mut diff_files: Vec<String> = Vec::new();
diff.foreach(
&mut |delta, _| {
if let Some(new_file) = delta.new_file().path() {
diff_files.push(String::from(new_file.to_str().unwrap()));
}
true
},
None,
None,
None,
)
.unwrap();
let mut ret: Vec<DiffFileContext> = Vec::new();
for each_file in diff_files {
let target_related_map: HashMap<String, RelatedFileContext> = target_graph
.related_files(each_file.clone())
.into_iter()
.map(|item| return (item.name.clone(), item))
.collect();
let source_related_map: HashMap<String, RelatedFileContext> = source_graph
.related_files(each_file.clone())
.into_iter()
.map(|item| return (item.name.clone(), item))
.collect();
let mut added_links: Vec<RelatedFileContext> = Vec::new();
let mut modified_links: Vec<RelatedFileContext> = Vec::new();
for (_, item) in source_related_map.clone() {
if !target_related_map.contains_key(&item.name) {
added_links.push(item);
} else {
modified_links.push(item);
}
}
let mut removed_links: Vec<RelatedFileContext> = Vec::new();
for (_, item) in target_related_map.clone() {
if !source_related_map.contains_key(&item.name) {
removed_links.push(item);
}
}
ret.push(DiffFileContext {
name: each_file,
added: added_links,
deleted: removed_links,
modified: modified_links,
})
}
if diff_cmd.json {
let json = serde_json::to_string(&ret).unwrap();
println!("{}", json);
} else {
for file_context in &ret {
let file_name = &file_context.name;
let mut file_node = Tree::new(file_name.as_str());
let mut names = Vec::new();
for link in &file_context.added {
names.push(format!("{} (ADDED)", link.name));
}
for link in &file_context.deleted {
names.push(format!("{} (DELETED)", link.name));
}
for link in &file_context.modified {
names.push(format!("{}", link.name));
}
for prefixed_name in &names {
file_node.push(Tree::new(prefixed_name.as_str()));
}
println!("{}", file_node)
}
}
}
#[test]
fn test_handle_relate() {
let relate_cmd = RelateCommand {
common_options: CommonOptions::default(),
file: "src/extractor.rs".to_string(),
file_txt: "".to_string(),
json: None,
ignore_zero: true,
};
handle_relate(relate_cmd);
}
#[test]
fn test_handle_relate_files() {
let relate_cmd = RelateCommand {
common_options: CommonOptions::default(),
file: "src/extractor.rs;src/main.rs;src/graph.rs".to_string(),
file_txt: "".to_string(),
json: None,
ignore_zero: true,
};
handle_relate(relate_cmd);
}
#[test]
fn test_handle_relate_files_strict() {
let relate_cmd = RelateCommand {
common_options: CommonOptions::default(),
file: "src/extractor.rs;src/rule.rs;src/main.rs;src/graph.rs".to_string(),
file_txt: "".to_string(),
json: None,
ignore_zero: true,
};
handle_relate(relate_cmd);
}
#[test]
#[ignore]
fn test_handle_relate_file_txt() {
let relate_cmd = RelateCommand {
common_options: CommonOptions::default(),
file: "".to_string(),
file_txt: "./aa.txt".to_string(),
json: None,
ignore_zero: true,
};
handle_relate(relate_cmd);
}
#[test]
#[ignore]
fn server_test() {
handle_server(ServerCommand {
common_options: CommonOptions::default(),
port: 9411,
})
}
#[test]
#[ignore]
fn obsidian_test() {
handle_obsidian(ObsidianCommand {
common_options: CommonOptions::default(),
vault_dir: "./vault".to_string(),
})
}
#[test]
fn diff_test() {
handle_diff(DiffCommand {
common_options: CommonOptions::default(),
target: "HEAD~10".to_string(),
source: "HEAD".to_string(),
json: false,
});
handle_diff(DiffCommand {
common_options: CommonOptions::default(),
target: "d18a5db39752d244664a23f74e174448b66b5b7e".to_string(),
source: "HEAD".to_string(),
json: false,
});
}
#[test]
fn relation_test() {
let mut config = CommonOptions::default();
config.exclude_file_regex = Some("".parse().unwrap());
config.project_path = ".".parse().unwrap();
handle_relation(RelationCommand {
common_options: config,
csv: "ok.csv".to_string(),
symbol_csv: "ok1.csv".to_string(),
index_file: "".to_string(),
})
}
#[test]
fn relation_v2_test() {
let mut config = CommonOptions::default();
config.exclude_file_regex = Some("".parse().unwrap());
config.project_path = ".".parse().unwrap();
handle_relation_v2(RelationCommand {
common_options: config,
csv: "".to_string(),
symbol_csv: "".to_string(),
index_file: "hello.index".to_string(),
})
}