use anyhow::{Context, Result};
use git2::{Repository, StatusOptions};
use magellan::output::{generate_execution_id, output_json, JsonResponse, OutputFormat};
use magellan::{CodeGraph, ReconcileOutcome};
use serde::Serialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Debug, Clone)]
pub struct RefreshArgs {
pub db_path: PathBuf,
pub dry_run: bool,
pub include_untracked: bool,
pub staged: bool,
pub unstaged: bool,
#[allow(dead_code, reason = "TODO: wire up force refresh logic")]
pub force: bool,
pub output_format: OutputFormat,
}
impl Default for RefreshArgs {
fn default() -> Self {
Self {
db_path: PathBuf::from(".magellan/magellan.db"),
dry_run: false,
include_untracked: false,
staged: false,
unstaged: false,
force: false,
output_format: OutputFormat::Human,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RefreshReport {
pub updated: Vec<String>,
pub deleted: Vec<String>,
pub added: Vec<String>,
pub unchanged: usize,
pub dry_run: bool,
pub duration_ms: u64,
}
impl RefreshReport {
pub fn new() -> Self {
Self {
updated: Vec::new(),
deleted: Vec::new(),
added: Vec::new(),
unchanged: 0,
dry_run: false,
duration_ms: 0,
}
}
#[allow(dead_code, reason = "used in tests, future public API")]
pub fn total_changes(&self) -> usize {
self.updated.len() + self.deleted.len() + self.added.len()
}
}
impl Default for RefreshReport {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize)]
struct RefreshResponse {
updated: Vec<String>,
deleted: Vec<String>,
added: Vec<String>,
unchanged: usize,
duration_ms: u64,
dry_run: bool,
}
impl RefreshResponse {
fn from_report(report: &RefreshReport, dry_run: bool) -> Self {
Self {
updated: report.updated.clone(),
deleted: report.deleted.clone(),
added: report.added.clone(),
unchanged: report.unchanged,
duration_ms: report.duration_ms,
dry_run,
}
}
}
pub fn run_refresh(args: &RefreshArgs) -> Result<RefreshReport> {
let start_time = Instant::now();
let exec_id = generate_execution_id();
let repo = Repository::open(".")
.context("Failed to open git repository. Are you in a git repository?")?;
let mut graph = CodeGraph::open(&args.db_path)?;
graph.execution_log().start_execution(
&exec_id,
env!("CARGO_PKG_VERSION"),
&["refresh".to_string()],
Some("."),
&args.db_path.to_string_lossy(),
)?;
let git_status = get_git_status(&repo, args)?;
let db_files = graph.all_file_nodes()?;
let db_file_paths: HashSet<String> = db_files.keys().cloned().collect();
let delta = compute_delta(&git_status, &db_file_paths, args)?;
if !args.dry_run {
apply_changes(&mut graph, &delta)?;
if let Err(e) = CodeGraph::rebuild_fts5_index(&args.db_path) {
eprintln!(" Warning: FTS5 rebuild failed: {}", e);
}
}
let mut report = RefreshReport::new();
report.updated = delta.to_update;
report.deleted = delta.to_delete;
report.added = delta.to_add;
report.unchanged = delta.unchanged;
report.dry_run = args.dry_run;
report.duration_ms = start_time.elapsed().as_millis() as u64;
match args.output_format {
OutputFormat::Json | OutputFormat::Pretty => {
let response = RefreshResponse::from_report(&report, args.dry_run);
let json_response = JsonResponse::new(response, &exec_id);
output_json(&json_response, args.output_format)?;
}
OutputFormat::Human => {
print_human_output(&report, args.dry_run);
}
}
let total_files = report.updated.len() + report.added.len();
graph.execution_log().finish_execution(
&exec_id,
"success",
None,
total_files,
0, 0, )?;
Ok(report)
}
#[derive(Debug, Clone)]
struct GitStatus {
modified: Vec<String>,
deleted: Vec<String>,
untracked: Vec<String>,
staged: Vec<String>,
unstaged: Vec<String>,
}
#[derive(Debug, Clone)]
struct FileDelta {
to_update: Vec<String>,
to_delete: Vec<String>,
to_add: Vec<String>,
unchanged: usize,
}
fn get_git_status(repo: &Repository, args: &RefreshArgs) -> Result<GitStatus> {
let mut status_opts = StatusOptions::new();
status_opts
.include_untracked(args.include_untracked)
.renames_head_to_index(true)
.renames_index_to_workdir(true);
if args.staged {
status_opts.include_untracked(false);
}
if args.unstaged {
}
let statuses = repo.statuses(Some(&mut status_opts))?;
let workdir = repo
.workdir()
.context("Failed to get repository working directory")?;
let mut modified = Vec::new();
let mut deleted = Vec::new();
let mut untracked = Vec::new();
let mut staged = Vec::new();
let mut unstaged = Vec::new();
for entry in statuses.iter() {
let rel_path = entry.path().unwrap_or("").to_string();
let path = workdir.join(&rel_path).to_string_lossy().to_string();
let status = entry.status();
let is_staged = status.is_index_new()
|| status.is_index_modified()
|| status.is_index_deleted()
|| status.is_index_renamed()
|| status.is_index_typechange();
let is_unstaged = status.is_wt_new()
|| status.is_wt_modified()
|| status.is_wt_deleted()
|| status.is_wt_renamed()
|| status.is_wt_typechange();
if is_staged {
staged.push(path.clone());
if status.is_index_modified() || status.is_index_renamed() {
modified.push(path.clone());
} else if status.is_index_deleted() {
deleted.push(path.clone());
} else if status.is_index_new() {
untracked.push(path.clone());
}
}
if is_unstaged {
unstaged.push(path.clone());
if status.is_wt_modified() || status.is_wt_renamed() {
modified.push(path.clone());
} else if status.is_wt_deleted() {
deleted.push(path.clone());
} else if status.is_wt_new() {
untracked.push(path.clone());
}
}
if status.is_wt_new() && !is_staged {
untracked.push(path);
}
}
modified.sort();
modified.dedup();
Ok(GitStatus {
modified,
deleted,
untracked,
staged,
unstaged,
})
}
fn compute_delta(
git_status: &GitStatus,
db_files: &HashSet<String>,
args: &RefreshArgs,
) -> Result<FileDelta> {
let mut to_update = Vec::new();
let mut to_delete = Vec::new();
let mut to_add = Vec::new();
let modified_files: HashSet<String> = if args.staged {
git_status.staged.iter().cloned().collect()
} else if args.unstaged {
git_status.unstaged.iter().cloned().collect()
} else {
git_status.modified.iter().cloned().collect()
};
let deleted_files: HashSet<String> = git_status.deleted.iter().cloned().collect();
let untracked_files: HashSet<String> = if args.include_untracked {
git_status.untracked.iter().cloned().collect()
} else {
HashSet::new()
};
for path in &modified_files {
if db_files.contains(path) {
to_update.push(path.clone());
} else if args.include_untracked && untracked_files.contains(path) {
to_add.push(path.clone());
}
}
for path in db_files {
if deleted_files.contains(path) {
to_delete.push(path.clone());
} else if !Path::new(path).exists() {
to_delete.push(path.clone());
}
}
if args.include_untracked {
for path in &untracked_files {
if !db_files.contains(path) && Path::new(path).exists() {
to_add.push(path.clone());
}
}
}
let all_affected: HashSet<String> = to_update
.iter()
.chain(to_delete.iter())
.chain(to_add.iter())
.cloned()
.collect();
let unchanged = db_files.difference(&all_affected).count();
to_update.sort();
to_delete.sort();
to_add.sort();
Ok(FileDelta {
to_update,
to_delete,
to_add,
unchanged,
})
}
fn apply_changes(graph: &mut CodeGraph, delta: &FileDelta) -> Result<()> {
for path_str in &delta.to_update {
let path = Path::new(path_str);
match graph.reconcile_file_path(path, path_str) {
Ok(ReconcileOutcome::Reindexed { symbols, .. }) => {
eprintln!(" Updated: {} ({} symbols)", path_str, symbols);
}
Ok(ReconcileOutcome::Unchanged) => {
eprintln!(" Unchanged: {}", path_str);
}
Ok(ReconcileOutcome::Deleted) => {
eprintln!(" Deleted during update: {}", path_str);
}
Err(e) => {
eprintln!(" Error updating {}: {}", path_str, e);
}
}
}
for path_str in &delta.to_delete {
match graph.delete_file_facts(path_str) {
Ok(result) => {
eprintln!(
" Deleted: {} ({} symbols, {} refs, {} calls)",
path_str,
result.symbols_deleted,
result.references_deleted,
result.calls_deleted
);
}
Err(e) => {
eprintln!(" Error deleting {}: {}", path_str, e);
}
}
}
for path_str in &delta.to_add {
let path = Path::new(path_str);
match graph.reconcile_file_path(path, path_str) {
Ok(ReconcileOutcome::Reindexed { symbols, .. }) => {
eprintln!(" Added: {} ({} symbols)", path_str, symbols);
}
Ok(ReconcileOutcome::Unchanged) => {
eprintln!(" Skipped (unchanged): {}", path_str);
}
Ok(ReconcileOutcome::Deleted) => {
eprintln!(" Skipped (deleted): {}", path_str);
}
Err(e) => {
eprintln!(" Error adding {}: {}", path_str, e);
}
}
}
Ok(())
}
fn print_human_output(report: &RefreshReport, dry_run: bool) {
let mode = if dry_run { " (dry run)" } else { "" };
println!("Refresh complete{}:", mode);
println!();
if report.updated.is_empty() && report.deleted.is_empty() && report.added.is_empty() {
println!(" No changes detected.");
println!(" {} files unchanged", report.unchanged);
} else {
if !report.updated.is_empty() {
println!(" Updated: {} files", report.updated.len());
for path in &report.updated {
println!(" - {}", path);
}
}
if !report.deleted.is_empty() {
println!(" Deleted: {} files", report.deleted.len());
for path in &report.deleted {
println!(" - {}", path);
}
}
if !report.added.is_empty() {
println!(" Added: {} files", report.added.len());
for path in &report.added {
println!(" - {}", path);
}
}
println!();
println!(" {} files unchanged", report.unchanged);
}
println!();
println!("Duration: {}ms", report.duration_ms);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_refresh_report_new() {
let report = RefreshReport::new();
assert!(report.updated.is_empty());
assert!(report.deleted.is_empty());
assert!(report.added.is_empty());
assert_eq!(report.unchanged, 0);
assert!(!report.dry_run);
assert_eq!(report.duration_ms, 0);
}
#[test]
fn test_refresh_report_total_changes() {
let report = RefreshReport {
updated: vec!["a.rs".to_string(), "b.rs".to_string()],
deleted: vec!["c.rs".to_string()],
added: vec!["d.rs".to_string()],
unchanged: 5,
dry_run: false,
duration_ms: 100,
};
assert_eq!(report.total_changes(), 4);
}
#[test]
fn test_refresh_args_default() {
let args = RefreshArgs::default();
assert_eq!(args.db_path, PathBuf::from(".magellan/magellan.db"));
assert!(!args.dry_run);
assert!(!args.include_untracked);
assert!(!args.staged);
assert!(!args.unstaged);
assert!(!args.force);
assert_eq!(args.output_format, OutputFormat::Human);
}
#[test]
fn test_compute_delta_basic() {
let git_status = GitStatus {
modified: vec!["src/main.rs".to_string()],
deleted: vec!["src/old.rs".to_string()],
untracked: vec![],
staged: vec!["src/main.rs".to_string()],
unstaged: vec![],
};
let mut db_files = HashSet::new();
db_files.insert("src/main.rs".to_string());
db_files.insert("src/old.rs".to_string());
let args = RefreshArgs::default();
let delta = compute_delta(&git_status, &db_files, &args).unwrap();
assert_eq!(delta.to_update, vec!["src/main.rs"]);
assert_eq!(delta.to_delete, vec!["src/old.rs"]);
assert!(delta.to_add.is_empty());
assert_eq!(delta.unchanged, 0);
}
#[test]
fn test_compute_delta_with_untracked() {
let git_status = GitStatus {
modified: vec![],
deleted: vec![],
untracked: vec!["src/new.rs".to_string()],
staged: vec![],
unstaged: vec!["src/new.rs".to_string()],
};
let mut db_files = HashSet::new();
db_files.insert("src/existing.rs".to_string());
let args = RefreshArgs {
include_untracked: true,
..Default::default()
};
let delta = compute_delta(&git_status, &db_files, &args).unwrap();
assert!(delta.to_add.is_empty() || delta.to_add == vec!["src/new.rs"]);
}
#[test]
fn test_refresh_response_from_report() {
let report = RefreshReport {
updated: vec!["a.rs".to_string()],
deleted: vec!["b.rs".to_string()],
added: vec!["c.rs".to_string()],
unchanged: 2,
dry_run: true,
duration_ms: 50,
};
let response = RefreshResponse::from_report(&report, true);
assert_eq!(response.updated, vec!["a.rs"]);
assert_eq!(response.deleted, vec!["b.rs"]);
assert_eq!(response.added, vec!["c.rs"]);
assert_eq!(response.unchanged, 2);
assert_eq!(response.duration_ms, 50);
assert!(response.dry_run);
}
}