use std::path::Path;
use colored::Colorize;
use sem_core::git::bridge::GitBridge;
use sem_core::model::entity::SemanticEntity;
use sem_core::parser::registry::ParserRegistry;
use super::truncate_str;
pub struct LogOptions {
pub cwd: String,
pub entity_name: String,
pub file_path: Option<String>,
pub limit: usize,
pub json: bool,
pub verbose: bool,
}
#[derive(Debug)]
enum EntityChangeType {
Added,
ModifiedLogic,
ModifiedCosmetic,
Deleted,
Moved,
Reappeared,
}
impl EntityChangeType {
fn label(&self) -> &str {
match self {
EntityChangeType::Added => "added",
EntityChangeType::ModifiedLogic => "modified (logic)",
EntityChangeType::ModifiedCosmetic => "modified (cosmetic)",
EntityChangeType::Deleted => "deleted",
EntityChangeType::Moved => "moved",
EntityChangeType::Reappeared => "reappeared",
}
}
fn label_colored(&self) -> colored::ColoredString {
match self {
EntityChangeType::Added => "added".green(),
EntityChangeType::ModifiedLogic => "modified (logic)".yellow(),
EntityChangeType::ModifiedCosmetic => "modified (cosmetic)".dimmed(),
EntityChangeType::Deleted => "deleted".red(),
EntityChangeType::Moved => "moved".blue(),
EntityChangeType::Reappeared => "reappeared".green(),
}
}
}
struct LogEntry {
short_sha: String,
author: String,
date: String,
message: String,
change_type: EntityChangeType,
content: Option<String>,
prev_content: Option<String>,
file_path: Option<String>,
prev_file_path: Option<String>,
}
pub fn log_command(opts: LogOptions) {
let root = Path::new(&opts.cwd);
let registry = super::create_registry(&opts.cwd);
let bridge = match GitBridge::open(root) {
Ok(b) => b,
Err(e) => {
eprintln!("{} {}", "error:".red().bold(), e);
std::process::exit(1);
}
};
let file_path = match opts.file_path {
Some(fp) => fp,
None => match find_entity_file(root, ®istry, &opts.entity_name) {
FindResult::Found(fp) => fp,
FindResult::Ambiguous(files) => {
eprintln!(
"{} Entity '{}' found in multiple files:",
"error:".red().bold(),
opts.entity_name
);
for f in &files {
eprintln!(" {}", f);
}
eprintln!("\nUse --file to disambiguate.");
std::process::exit(1);
}
FindResult::NotFound => {
eprintln!(
"{} Entity '{}' not found in any file",
"error:".red().bold(),
opts.entity_name
);
std::process::exit(1);
}
},
};
let repo_root = bridge.repo_root();
let abs_cwd = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let abs_repo = std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
let git_file_path = if abs_cwd != abs_repo {
let prefix = abs_cwd.strip_prefix(&abs_repo).unwrap_or(Path::new(""));
prefix.join(&file_path).to_string_lossy().to_string()
} else {
file_path.clone()
};
let file_content_hint = std::fs::read_to_string(root.join(&file_path)).unwrap_or_default();
let resolved_fp = registry.resolve_file_path(&file_path);
let detection_fp = resolved_fp.as_deref().unwrap_or(&file_path);
if registry.get_plugin_with_content(detection_fp, &file_content_hint).is_none() {
eprintln!(
"{} Unsupported file type: {}",
"error:".red().bold(),
file_path
);
std::process::exit(1);
}
let mut current_git_file = git_file_path.clone();
let mut entries: Vec<LogEntry> = Vec::new();
let mut prev_entity_content: Option<String> = None;
let mut prev_structural_hash: Option<String> = None;
let mut entity_type = String::new();
let mut found_at_least_once = false;
let mut total_commits = 0usize;
let mut skip_until_sha: Option<String> = None;
loop {
let commits = match bridge.get_file_commits(¤t_git_file, opts.limit) {
Ok(c) => c,
Err(e) => {
if total_commits == 0 {
eprintln!("{} Failed to get file history: {}", "error:".red().bold(), e);
std::process::exit(1);
}
break;
}
};
if commits.is_empty() && total_commits == 0 {
eprintln!("{} No commits found for {}", "warning:".yellow().bold(), current_git_file);
return;
}
let reversed: Vec<_> = commits.iter().rev().collect();
let start_idx = if let Some(ref sha) = skip_until_sha {
reversed
.iter()
.position(|c| c.sha == *sha)
.map(|i| i + 1)
.unwrap_or(reversed.len())
} else {
0
};
skip_until_sha = None;
total_commits += reversed.len().saturating_sub(start_idx);
let mut moved = false;
for commit in &reversed[start_idx..] {
let file_content = bridge
.read_file_at_ref(&commit.sha, ¤t_git_file)
.ok()
.flatten();
let found_entity = file_content.as_ref().and_then(|c| {
let entities = registry.extract_entities(¤t_git_file, c);
entities.into_iter().find(|e| e.name == opts.entity_name)
});
let date = chrono_lite_format(commit.date.parse::<i64>().unwrap_or(0));
let msg_first_line = commit.message.lines().next().unwrap_or("").to_string();
match found_entity {
Some(ent) => {
if !found_at_least_once {
entity_type = ent.entity_type.clone();
}
let cur_content_hash = &ent.content_hash;
let cur_structural_hash = ent.structural_hash.as_deref();
if !found_at_least_once {
found_at_least_once = true;
entries.push(LogEntry {
short_sha: commit.short_sha.clone(),
author: commit.author.clone(),
date,
message: msg_first_line,
change_type: EntityChangeType::Added,
content: Some(ent.content.clone()),
prev_content: None,
file_path: Some(current_git_file.clone()),
prev_file_path: None,
});
} else if prev_entity_content.is_none() {
entries.push(LogEntry {
short_sha: commit.short_sha.clone(),
author: commit.author.clone(),
date,
message: msg_first_line,
change_type: EntityChangeType::Reappeared,
content: Some(ent.content.clone()),
prev_content: None,
file_path: Some(current_git_file.clone()),
prev_file_path: None,
});
} else {
let prev_hash = prev_entity_content
.as_ref()
.map(|c| sem_core::utils::hash::content_hash(c));
let content_changed =
prev_hash.as_deref() != Some(cur_content_hash.as_str());
if content_changed {
let structural_changed =
match (cur_structural_hash, prev_structural_hash.as_deref()) {
(Some(cur), Some(prev)) => cur != prev,
_ => true,
};
let change_type = if structural_changed {
EntityChangeType::ModifiedLogic
} else {
EntityChangeType::ModifiedCosmetic
};
entries.push(LogEntry {
short_sha: commit.short_sha.clone(),
author: commit.author.clone(),
date,
message: msg_first_line,
change_type,
content: Some(ent.content.clone()),
prev_content: prev_entity_content.clone(),
file_path: Some(current_git_file.clone()),
prev_file_path: None,
});
}
}
prev_entity_content = Some(ent.content.clone());
prev_structural_hash = ent.structural_hash.clone();
}
None => {
if prev_entity_content.is_some() {
let cross = search_entity_cross_file(
&bridge,
®istry,
&commit.sha,
&opts.entity_name,
prev_structural_hash.as_deref(),
¤t_git_file,
);
match cross {
Some((new_file, ent)) => {
let prev_file = current_git_file.clone();
entries.push(LogEntry {
short_sha: commit.short_sha.clone(),
author: commit.author.clone(),
date,
message: msg_first_line,
change_type: EntityChangeType::Moved,
content: Some(ent.content.clone()),
prev_content: prev_entity_content.clone(),
file_path: Some(new_file.clone()),
prev_file_path: Some(prev_file),
});
prev_entity_content = Some(ent.content.clone());
prev_structural_hash = ent.structural_hash.clone();
skip_until_sha = Some(commit.sha.clone());
current_git_file = new_file;
moved = true;
break;
}
None => {
entries.push(LogEntry {
short_sha: commit.short_sha.clone(),
author: commit.author.clone(),
date,
message: msg_first_line,
change_type: EntityChangeType::Deleted,
content: None,
prev_content: prev_entity_content.take(),
file_path: Some(current_git_file.clone()),
prev_file_path: None,
});
prev_structural_hash = None;
}
}
}
}
}
}
if !moved {
break;
}
}
if !found_at_least_once {
eprintln!(
"{} Entity '{}' not found in any commit of {}",
"error:".red().bold(),
opts.entity_name,
file_path
);
std::process::exit(1);
}
let first_seen = entries.first().map(|e| e.date.clone()).unwrap_or_default();
let display_file = entries
.iter()
.rev()
.find_map(|e| e.file_path.as_ref())
.unwrap_or(&file_path)
.clone();
let was_file = entries
.iter()
.find_map(|e| {
if matches!(e.change_type, EntityChangeType::Moved) {
e.prev_file_path.as_ref().cloned()
} else {
None
}
});
if opts.json {
print_json(&opts.entity_name, &display_file, &entity_type, &entries, opts.verbose);
} else {
print_terminal(&opts.entity_name, &display_file, was_file.as_deref(), &entity_type, &entries, total_commits, &first_seen, opts.verbose);
}
}
fn print_terminal(
entity_name: &str,
file_path: &str,
was_file: Option<&str>,
entity_type: &str,
entries: &[LogEntry],
total_commits: usize,
first_seen: &str,
verbose: bool,
) {
let header = if let Some(prev) = was_file {
format!(
"┌─ {} :: {} :: {} (was: {})",
file_path, entity_type, entity_name, prev
)
} else {
format!("┌─ {} :: {} :: {}", file_path, entity_type, entity_name)
};
println!("{}", header.bold());
println!("│");
let max_author_len = entries.iter().map(|e| e.author.len()).max().unwrap_or(6);
let max_change_len = entries
.iter()
.map(|e| e.change_type.label().len())
.max()
.unwrap_or(10);
for entry in entries {
let msg_short = truncate_str(&entry.message, 50);
println!(
"│ {} {:<max_author$} {} {:<max_change$} {}",
entry.short_sha.yellow(),
entry.author.cyan(),
entry.date.dimmed(),
entry.change_type.label_colored(),
msg_short,
max_author = max_author_len,
max_change = max_change_len,
);
if matches!(entry.change_type, EntityChangeType::Moved) {
if let Some(new_fp) = &entry.file_path {
println!(
"│ {}",
format!("→ moved to {}", new_fp).blue()
);
}
}
if verbose {
if let (Some(prev), Some(cur)) = (&entry.prev_content, &entry.content) {
print_inline_diff(prev, cur);
} else if let Some(cur) = &entry.content {
for line in cur.lines() {
println!("│ {}", format!("+ {}", line).green());
}
println!("│");
}
}
}
println!("│");
println!(
"│ {}",
format!(
"{} changes across {} commits (first seen: {})",
entries.len(),
total_commits,
first_seen
)
.dimmed()
);
println!("└{}", "─".repeat(60));
}
fn print_inline_diff(before: &str, after: &str) {
use similar::TextDiff;
let diff = TextDiff::from_lines(before, after);
let mut has_changes = false;
for change in diff.iter_all_changes() {
match change.tag() {
similar::ChangeTag::Delete => {
has_changes = true;
print!("│ {}", format!("- {}", change).red());
}
similar::ChangeTag::Insert => {
has_changes = true;
print!("│ {}", format!("+ {}", change).green());
}
similar::ChangeTag::Equal => {} }
}
if has_changes {
println!("│");
}
}
fn print_json(
entity_name: &str,
file_path: &str,
entity_type: &str,
entries: &[LogEntry],
verbose: bool,
) {
let json_entries: Vec<_> = entries
.iter()
.map(|e| {
let mut obj = serde_json::json!({
"commit": {
"sha": e.short_sha,
"author": e.author,
"date": e.date,
"message": e.message,
},
"change_type": e.change_type.label(),
"structural_change": matches!(e.change_type, EntityChangeType::ModifiedLogic | EntityChangeType::Added),
});
if let Some(fp) = &e.file_path {
obj["file_path"] = serde_json::Value::String(fp.clone());
}
if let Some(pfp) = &e.prev_file_path {
obj["prev_file_path"] = serde_json::Value::String(pfp.clone());
}
if verbose {
if let Some(content) = &e.content {
obj["after_content"] = serde_json::Value::String(content.clone());
}
if let Some(prev) = &e.prev_content {
obj["before_content"] = serde_json::Value::String(prev.clone());
}
}
obj
})
.collect();
let output = serde_json::json!({
"entity": entity_name,
"file": file_path,
"type": entity_type,
"changes": json_entries,
});
println!("{}", serde_json::to_string(&output).unwrap());
}
fn search_entity_cross_file(
bridge: &GitBridge,
registry: &ParserRegistry,
sha: &str,
entity_name: &str,
prev_structural_hash: Option<&str>,
exclude_file: &str,
) -> Option<(String, SemanticEntity)> {
let changed_files = bridge.get_commit_changed_files(sha).ok()?;
for file_path in &changed_files {
if file_path == exclude_file {
continue;
}
let content = match bridge.read_file_at_ref(sha, file_path) {
Ok(Some(c)) => c,
_ => continue,
};
let entities = registry.extract_entities(file_path, &content);
if let Some(ent) = entities.into_iter().find(|e| e.name == entity_name) {
return Some((file_path.clone(), ent));
}
}
let prev_hash = prev_structural_hash?;
for file_path in &changed_files {
if file_path == exclude_file {
continue;
}
let content = match bridge.read_file_at_ref(sha, file_path) {
Ok(Some(c)) => c,
_ => continue,
};
let entities = registry.extract_entities(file_path, &content);
if let Some(ent) = entities
.into_iter()
.find(|e| e.structural_hash.as_deref() == Some(prev_hash))
{
return Some((file_path.clone(), ent));
}
}
None
}
enum FindResult {
Found(String),
Ambiguous(Vec<String>),
NotFound,
}
fn find_entity_file(
root: &Path,
registry: &sem_core::parser::registry::ParserRegistry,
entity_name: &str,
) -> FindResult {
let ext_filter: Vec<String> = vec![];
let files = super::graph::find_supported_files_public(root, registry, &ext_filter);
let mut found_in: Vec<String> = Vec::new();
for file_path in &files {
let full_path = root.join(file_path);
let content = match std::fs::read_to_string(&full_path) {
Ok(c) => c,
Err(_) => continue,
};
let entities = registry.extract_entities(file_path, &content);
if entities.iter().any(|e| e.name == entity_name) {
found_in.push(file_path.clone());
}
}
match found_in.len() {
0 => FindResult::NotFound,
1 => FindResult::Found(found_in.into_iter().next().unwrap()),
_ => FindResult::Ambiguous(found_in),
}
}
fn chrono_lite_format(unix_seconds: i64) -> String {
let days = unix_seconds / 86400;
let mut y = 1970i64;
let mut remaining_days = days;
loop {
let year_days = if is_leap(y) { 366 } else { 365 };
if remaining_days < year_days {
break;
}
remaining_days -= year_days;
y += 1;
}
let month_days = if is_leap(y) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut m = 0;
for (i, &md) in month_days.iter().enumerate() {
if remaining_days < md {
m = i;
break;
}
remaining_days -= md;
}
let d = remaining_days + 1;
format!("{:04}-{:02}-{:02}", y, m + 1, d)
}
fn is_leap(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}