use std::collections::{HashMap, HashSet};
use std::fs;
use crate::core::{Project, Status, commit_to_branch, remove_file_from_branch};
use crate::error::Result;
use crate::storage::Database;
use crate::storage::db::{IssueInfo, load_all_issues_from_data_branch};
use crate::storage::markdown;
const EXPECTED_SCHEMA_VERSION: i32 = 1;
pub fn run() -> Result<()> {
let project = Project::discover()?;
let mut has_issues = false;
let data_branch = project
.config
.data_branch
.as_deref()
.unwrap_or("data/itack");
println!("Checking database schema version...");
match check_schema_version(&project) {
Ok(db_version) => {
if db_version == EXPECTED_SCHEMA_VERSION {
println!(" ✓ Database schema version: {} (matches CLI)", db_version);
} else {
println!(
" ✗ Database schema version mismatch: DB has {}, CLI expects {}",
db_version, EXPECTED_SCHEMA_VERSION
);
println!(" Run 'itack init' to repair the database.");
has_issues = true;
}
}
Err(e) => {
println!(" ✗ Could not read database schema version: {}", e);
println!(" Run 'itack init' to repair the database.");
has_issues = true;
}
}
println!("\nChecking issue synchronization...");
match check_issue_sync(&project, data_branch) {
Ok(sync_result) => {
if sync_result.is_ok() {
println!(
" ✓ Issues in sync: {} issues found in '{}'",
sync_result.issue_count, data_branch
);
} else {
has_issues = true;
if !sync_result.missing_claims.is_empty() {
println!(
" ✗ In-progress issues without database claims: {:?}",
sync_result.missing_claims
);
}
if !sync_result.orphan_claims.is_empty() {
println!(
" ✗ Claims in database for non-existent issues: {:?}",
sync_result.orphan_claims
);
}
if let Some(msg) = &sync_result.next_id_issue {
println!(" ✗ {}", msg);
}
println!(" Run 'itack init' to repair the database.");
}
}
Err(e) => {
println!(" ✗ Could not check issue synchronization: {}", e);
has_issues = true;
}
}
println!("\nChecking for duplicate issue IDs...");
match check_duplicate_ids(&project, data_branch) {
Ok(duplicates) => {
if duplicates.is_empty() {
println!(" ✓ No duplicate issue IDs found");
} else {
has_issues = true;
println!(
" ✗ Found {} issue(s) with duplicate IDs, renumbering...",
duplicates.len()
);
match fix_duplicate_ids(&project, data_branch, &duplicates) {
Ok(renames) => {
for (old_id, new_id, title) in &renames {
println!(" Renumbered #{} → #{}: {}", old_id, new_id, title);
}
println!(" Run 'itack init' to rebuild the database.");
}
Err(e) => {
println!(" ✗ Failed to fix duplicates: {}", e);
}
}
}
}
Err(e) => {
println!(" ✗ Could not check for duplicates: {}", e);
has_issues = true;
}
}
println!("\nChecking for stray issue files in working directory...");
match find_stray_issue_files(&project) {
Ok(stray_files) => {
if stray_files.is_empty() {
println!(" ✓ No stray issue files found");
} else {
has_issues = true;
println!(
" ✗ Found {} stray issue file(s) in .itack/:",
stray_files.len()
);
for file in &stray_files {
println!(" - {}", file);
}
println!(" Run 'itack init' to migrate them to the data branch.");
}
}
Err(e) => {
println!(" ✗ Could not check for stray files: {}", e);
has_issues = true;
}
}
println!();
if has_issues {
println!("Issues found. Run 'itack init' to repair.");
std::process::exit(1);
} else {
println!("All checks passed.");
}
Ok(())
}
fn find_stray_issue_files(project: &Project) -> Result<Vec<String>> {
let itack_dir = project.repo_root.join(".itack");
if !itack_dir.is_dir() {
return Ok(Vec::new());
}
let mut stray_files = Vec::new();
for entry in fs::read_dir(&itack_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
if markdown::parse_issue(&content).is_ok() {
stray_files.push(path.file_name().unwrap().to_string_lossy().to_string());
}
}
stray_files.sort();
Ok(stray_files)
}
fn check_schema_version(project: &Project) -> Result<i32> {
let data_branch = project.config.data_branch.as_deref();
let db = Database::open(&project.db_path, Some(&project.repo_root), data_branch)?;
db.get_schema_version()
}
struct SyncCheckResult {
issue_count: usize,
missing_claims: Vec<u32>,
orphan_claims: Vec<u32>,
next_id_issue: Option<String>,
}
impl SyncCheckResult {
fn is_ok(&self) -> bool {
self.missing_claims.is_empty()
&& self.orphan_claims.is_empty()
&& self.next_id_issue.is_none()
}
}
fn check_issue_sync(project: &Project, data_branch: &str) -> Result<SyncCheckResult> {
let db = Database::open(
&project.db_path,
Some(&project.repo_root),
Some(data_branch),
)?;
let issues = load_all_issues_from_data_branch(&project.repo_root, data_branch)?;
let issue_ids: HashSet<u32> = issues.iter().map(|i| i.issue.id).collect();
let max_issue_id = issues.iter().map(|i| i.issue.id).max().unwrap_or(0);
let claims = db.list_claims()?;
let claimed_ids: HashSet<u32> = claims.iter().map(|(id, _, _)| *id).collect();
let mut missing_claims: Vec<u32> = issues
.iter()
.filter(|i| i.issue.status == Status::InProgress && !claimed_ids.contains(&i.issue.id))
.map(|i| i.issue.id)
.collect();
missing_claims.sort();
let mut orphan_claims: Vec<u32> = claimed_ids.difference(&issue_ids).copied().collect();
orphan_claims.sort();
let next_id = db.peek_next_issue_id()?;
let next_id_issue = if !issues.is_empty() && next_id <= max_issue_id {
Some(format!(
"next_issue_id ({}) is not greater than max issue ID ({})",
next_id, max_issue_id
))
} else {
None
};
Ok(SyncCheckResult {
issue_count: issues.len(),
missing_claims,
orphan_claims,
next_id_issue,
})
}
fn check_duplicate_ids(project: &Project, data_branch: &str) -> Result<Vec<IssueInfo>> {
let issues = load_all_issues_from_data_branch(&project.repo_root, data_branch)?;
let mut seen: HashMap<u32, usize> = HashMap::new();
let mut duplicates = Vec::new();
let mut sorted = issues;
sorted.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
for info in sorted {
let count = seen.entry(info.issue.id).or_insert(0);
*count += 1;
if *count > 1 {
duplicates.push(info);
}
}
Ok(duplicates)
}
fn fix_duplicate_ids(
project: &Project,
data_branch: &str,
duplicates: &[IssueInfo],
) -> Result<Vec<(u32, u32, String)>> {
let db = project.open_db()?;
let mut renames = Vec::new();
for dup in duplicates {
let new_id = db.next_issue_id()?;
let mut new_issue = dup.issue.clone();
new_issue.id = new_id;
let new_path = Project::issue_relative_path(new_id, &new_issue.created);
let content = markdown::format_issue(&new_issue, &dup.title, &dup.body)?;
commit_to_branch(
&project.repo_root,
data_branch,
&new_path,
content.as_bytes(),
&format!(
"Renumber duplicate #{} → #{}: {}",
dup.issue.id, new_id, dup.title
),
)?;
remove_file_from_branch(
&project.repo_root,
data_branch,
&dup.relative_path,
&format!("Remove duplicate file {}", dup.relative_path.display()),
)?;
renames.push((dup.issue.id, new_id, dup.title.clone()));
}
Ok(renames)
}