use anyhow::Result;
use colored::Colorize;
use std::collections::HashMap;
use std::path::Path;
use thoughts_tool::config::RepoMapping;
use thoughts_tool::config::RepoMappingManager;
use thoughts_tool::config::repo_mapping_manager::parse_reference_mapping_storage_key;
use thoughts_tool::config::repo_mapping_manager::parse_url_and_subpath;
use thoughts_tool::git::utils::is_git_repo;
use thoughts_tool::git::utils::try_get_origin_identity;
use thoughts_tool::repo_identity::RepoIdentity;
use thoughts_tool::repo_identity::RepoIdentityKey;
use thoughts_tool::utils::paths::get_repo_mapping_path;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct InstanceKey {
repo: RepoIdentityKey,
ref_key: Option<String>,
}
#[derive(Debug)]
enum Issue {
DuplicateIdentity {
canonical_key: String,
urls: Vec<String>,
#[expect(dead_code)]
auto_managed: bool,
},
MissingPath {
url: String,
path: String,
auto_managed: bool,
},
NotADirectory {
url: String,
path: String,
auto_managed: bool,
},
NotAGitRepo {
url: String,
path: String,
auto_managed: bool,
},
OriginMismatch {
url: String,
path: String,
expected: String,
actual: String,
auto_managed: bool,
},
}
impl Issue {
fn print(&self) {
match self {
Self::DuplicateIdentity {
canonical_key,
urls,
auto_managed: _,
} => {
println!(
"{} Duplicate canonical identity: {}",
"⚠".yellow(),
canonical_key
);
for url in urls {
println!(" - {url}");
}
println!(
" Fix: Run 'thoughts references doctor --fix' to consolidate mappings deterministically."
);
}
Self::MissingPath {
url,
path,
auto_managed,
} => {
println!("{} Missing path for {}", "✗".red(), url);
println!(" Path: {path}");
if *auto_managed {
println!(
" Fix: Run 'thoughts references doctor --fix' to remove the stale auto-managed mapping."
);
} else {
println!(
" Fix: This mapping is user-managed; update/remove it in repos.json (doctor --fix will not modify user-managed entries)."
);
}
}
Self::NotADirectory {
url,
path,
auto_managed,
} => {
println!("{} Path is not a directory for {}", "✗".red(), url);
println!(" Path: {path}");
if *auto_managed {
println!(
" Fix: Run 'thoughts references doctor --fix' to remove the stale auto-managed mapping."
);
} else {
println!(
" Fix: This mapping is user-managed; update/remove it in repos.json (doctor --fix will not modify user-managed entries)."
);
}
}
Self::NotAGitRepo {
url,
path,
auto_managed,
} => {
println!("{} Path is not a git repository for {}", "⚠".yellow(), url);
println!(" Path: {path}");
if *auto_managed {
println!(
" Fix: Run 'thoughts references doctor --fix' to remove the stale auto-managed mapping."
);
} else {
println!(
" Fix: This mapping is user-managed; update/remove it in repos.json (doctor --fix will not modify user-managed entries)."
);
}
}
Self::OriginMismatch {
url,
path,
expected,
actual,
auto_managed,
} => {
println!("{} Origin mismatch for {}", "⚠".yellow(), url);
println!(" Path: {path}");
println!(" Expected identity: {expected}");
println!(" Actual origin: {actual}");
if *auto_managed {
println!(
" Fix: Run 'thoughts references doctor --fix' to remove the auto-managed mapping."
);
println!(
" Then re-clone the correct repo (doctor does not delete directories; you may need to remove/rename the existing directory)."
);
} else {
println!(
" Fix: This mapping is user-managed; update the mapping URL or fix the repository origin manually."
);
}
}
}
}
}
#[expect(clippy::unused_async, reason = "async for command API consistency")]
pub async fn execute(fix: bool) -> Result<()> {
let mapping_path = get_repo_mapping_path()?;
if !mapping_path.exists() {
println!("{} No repos.json found - nothing to diagnose", "✓".green());
return Ok(());
}
let contents = std::fs::read_to_string(&mapping_path)?;
let mapping: RepoMapping = serde_json::from_str(&contents)?;
if mapping.mappings.is_empty() {
println!("{} repos.json is empty - nothing to diagnose", "✓".green());
return Ok(());
}
println!("Checking {} mappings...\n", mapping.mappings.len());
let mut issues: Vec<Issue> = Vec::new();
let identity_map = build_identity_groups(&mapping);
for (url, location) in &mapping.mappings {
let path = &location.path;
let path_str = path.display().to_string();
let (stored_url, _) = parse_reference_mapping_storage_key(url);
let (base_url, _) = parse_url_and_subpath(&stored_url);
let identity = if let Ok(id) = RepoIdentity::parse(&base_url) {
Some(id)
} else {
println!("{} Cannot parse URL: {} (skipping)", "⚠".yellow(), url);
None
};
if !path.exists() {
issues.push(Issue::MissingPath {
url: url.clone(),
path: path_str.clone(),
auto_managed: location.auto_managed,
});
continue;
}
if !path.is_dir() {
issues.push(Issue::NotADirectory {
url: url.clone(),
path: path_str.clone(),
auto_managed: location.auto_managed,
});
continue;
}
if !is_git_repo(path) {
issues.push(Issue::NotAGitRepo {
url: url.clone(),
path: path_str.clone(),
auto_managed: location.auto_managed,
});
continue;
}
if let Some(ref expected_id) = identity {
match try_get_origin_identity(path) {
Ok(Some(actual_id)) => {
let expected_key = expected_id.canonical_key();
let actual_key = actual_id.canonical_key();
if expected_key != actual_key {
issues.push(Issue::OriginMismatch {
url: url.clone(),
path: path_str.clone(),
expected: format!(
"{}/{}/{}",
expected_key.host, expected_key.org_path, expected_key.repo
),
actual: format!(
"{}/{}/{}",
actual_key.host, actual_key.org_path, actual_key.repo
),
auto_managed: location.auto_managed,
});
}
}
Ok(None) => {
}
Err(e) => {
tracing::warn!(
path = %path_str,
error = ?e,
"Could not verify origin identity (skipping origin check)"
);
}
}
}
}
for (key, urls) in &identity_map {
if urls.len() > 1 {
let all_auto = urls
.iter()
.all(|u| mapping.mappings.get(u).is_some_and(|loc| loc.auto_managed));
issues.push(Issue::DuplicateIdentity {
canonical_key: format_instance_key(key),
urls: urls.clone(),
auto_managed: all_auto,
});
}
}
if issues.is_empty() {
println!("{} All mappings are healthy!", "✓".green());
return Ok(());
}
println!("Found {} issue(s):\n", issues.len());
for issue in &issues {
issue.print();
println!();
}
if fix {
apply_fixes(&issues)?;
} else {
println!(
"Run with {} to apply safe automatic repairs.",
"--fix".cyan()
);
}
Ok(())
}
fn health_rank(url: &str, path: &Path) -> u8 {
if !path.exists() || !path.is_dir() {
return 0;
}
if !is_git_repo(path) {
return 1;
}
let (stored_url, _) = parse_reference_mapping_storage_key(url);
let (base_url, _) = parse_url_and_subpath(&stored_url);
let expected = match RepoIdentity::parse(&base_url) {
Ok(id) => id.canonical_key(),
Err(_) => return 3, };
match try_get_origin_identity(path) {
Ok(Some(actual)) => {
if actual.canonical_key() == expected {
4 } else {
2 }
}
Ok(None) | Err(_) => 3,
}
}
fn build_identity_groups(mapping: &RepoMapping) -> HashMap<InstanceKey, Vec<String>> {
let mut identity_map: HashMap<InstanceKey, Vec<String>> = HashMap::new();
for url in mapping.mappings.keys() {
let (stored_url, stored_ref_key) = parse_reference_mapping_storage_key(url);
let (base_url, _) = parse_url_and_subpath(&stored_url);
if let Ok(identity) = RepoIdentity::parse(&base_url) {
let key = InstanceKey {
repo: identity.canonical_key(),
ref_key: stored_ref_key,
};
identity_map.entry(key).or_default().push(url.clone());
}
}
identity_map
}
fn format_instance_key(key: &InstanceKey) -> String {
let repo = format!("{}/{}/{}", key.repo.host, key.repo.org_path, key.repo.repo);
match &key.ref_key {
Some(ref_key) => format!("{repo} [ref_key={ref_key}]"),
None => repo,
}
}
fn apply_fixes(issues: &[Issue]) -> Result<()> {
let mapping_mgr = RepoMappingManager::new()?;
let (mut mapping, _lock) = mapping_mgr.load_locked()?;
let mut fixed_count = 0;
for issue in issues {
match issue {
Issue::MissingPath { url, .. }
| Issue::NotADirectory { url, .. }
| Issue::NotAGitRepo { url, .. }
| Issue::OriginMismatch { url, .. } => {
if let Some(loc) = mapping.mappings.get(url)
&& loc.auto_managed
{
println!(
"{} Removing broken auto-managed entry: {}",
"↻".green(),
url
);
mapping.mappings.remove(url);
fixed_count += 1;
}
}
Issue::DuplicateIdentity { urls, .. } => {
let mut candidates: Vec<(String, u8, bool, Option<chrono::DateTime<chrono::Utc>>)> =
urls.iter()
.filter_map(|u| {
let loc = mapping.mappings.get(u)?;
let rank = health_rank(u, &loc.path);
let user_specified = !loc.auto_managed;
Some((u.clone(), rank, user_specified, loc.last_sync))
})
.collect();
if candidates.len() <= 1 {
continue;
}
let best_last_sync = candidates.iter().filter_map(|c| c.3).max();
candidates.sort_by(|a, b| {
b.1.cmp(&a.1) .then_with(|| b.2.cmp(&a.2)) .then_with(|| b.3.cmp(&a.3)) .then_with(|| a.0.cmp(&b.0)) });
let winner_url = candidates[0].0.clone();
println!("{} Keeping winner entry: {}", "✓".green(), winner_url);
for (loser_url, ..) in candidates.iter().skip(1) {
println!("{} Removing duplicate entry: {}", "↻".green(), loser_url);
mapping.mappings.remove(loser_url);
fixed_count += 1;
}
if let Some(ts) = best_last_sync
&& let Some(loc) = mapping.mappings.get_mut(&winner_url)
{
loc.last_sync = Some(ts);
}
}
}
}
if fixed_count > 0 {
mapping_mgr.save(&mapping)?;
println!("\n{} Applied {} fix(es)", "✓".green(), fixed_count);
} else {
println!("\n{} No automatic fixes could be applied", "⚠".yellow());
println!("Manual intervention required for remaining issues.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use thoughts_tool::config::RepoLocation;
#[test]
fn test_health_rank_missing_path_returns_0() {
let path = std::path::Path::new("/nonexistent/path/that/does/not/exist");
let rank = health_rank("https://github.com/org/repo", path);
assert_eq!(rank, 0);
}
#[test]
fn test_health_rank_file_not_dir_returns_0() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("not_a_dir.txt");
std::fs::write(&file_path, "content").unwrap();
let rank = health_rank("https://github.com/org/repo", &file_path);
assert_eq!(rank, 0);
}
#[test]
fn test_health_rank_dir_not_git_returns_1() {
let dir = tempdir().unwrap();
let rank = health_rank("https://github.com/org/repo", dir.path());
assert_eq!(rank, 1);
}
#[test]
fn test_health_rank_git_repo_matching_origin_returns_4() {
let dir = tempdir().unwrap();
let repo_path = dir.path();
let repo = git2::Repository::init(repo_path).unwrap();
repo.remote("origin", "https://github.com/org/repo.git")
.unwrap();
let rank = health_rank("https://github.com/org/repo", repo_path);
assert_eq!(rank, 4);
}
#[test]
fn test_health_rank_ref_specific_key_matching_origin_returns_4() {
let dir = tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
repo.remote("origin", "https://github.com/org/repo.git")
.unwrap();
let key = "https://github.com/org/repo#thoughts-ref=r-refs~2fheads~2fmain";
let rank = health_rank(key, dir.path());
assert_eq!(rank, 4);
}
#[test]
fn test_health_rank_git_repo_mismatched_origin_returns_2() {
let dir = tempdir().unwrap();
let repo_path = dir.path();
let repo = git2::Repository::init(repo_path).unwrap();
repo.remote("origin", "https://github.com/other-org/other-repo.git")
.unwrap();
let rank = health_rank("https://github.com/org/repo", repo_path);
assert_eq!(rank, 2);
}
#[test]
fn test_health_rank_git_repo_no_origin_returns_3() {
let dir = tempdir().unwrap();
let repo_path = dir.path();
git2::Repository::init(repo_path).unwrap();
let rank = health_rank("https://github.com/org/repo", repo_path);
assert_eq!(rank, 3);
}
#[test]
fn test_health_rank_unparseable_url_returns_3() {
let dir = tempdir().unwrap();
let repo_path = dir.path();
git2::Repository::init(repo_path).unwrap();
let rank = health_rank("not-a-valid-url", repo_path);
assert_eq!(rank, 3);
}
#[test]
fn test_duplicate_sorting_prefers_higher_health() {
let t1 = chrono::Utc::now();
let mut candidates = [
("url_a".to_string(), 2u8, true, Some(t1)), ("url_b".to_string(), 4u8, true, Some(t1)), ];
candidates.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.2.cmp(&a.2))
.then_with(|| b.3.cmp(&a.3))
.then_with(|| a.0.cmp(&b.0))
});
assert_eq!(candidates[0].0, "url_b");
}
#[test]
fn test_duplicate_sorting_prefers_user_specified_when_health_equal() {
let t1 = chrono::Utc::now();
let mut candidates = [
("url_a".to_string(), 4u8, false, Some(t1)), ("url_b".to_string(), 4u8, true, Some(t1)), ];
candidates.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.2.cmp(&a.2))
.then_with(|| b.3.cmp(&a.3))
.then_with(|| a.0.cmp(&b.0))
});
assert_eq!(candidates[0].0, "url_b");
}
#[test]
fn test_duplicate_sorting_prefers_newer_sync_when_tied() {
let t1 = chrono::Utc::now();
let t2 = t1 + chrono::Duration::hours(1);
let mut candidates = [
("url_a".to_string(), 4u8, true, Some(t1)), ("url_b".to_string(), 4u8, true, Some(t2)), ];
candidates.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.2.cmp(&a.2))
.then_with(|| b.3.cmp(&a.3))
.then_with(|| a.0.cmp(&b.0))
});
assert_eq!(candidates[0].0, "url_b");
}
#[test]
fn test_duplicate_sorting_uses_url_alphabetical_as_tiebreaker() {
let t1 = chrono::Utc::now();
let mut candidates = [
("url_z".to_string(), 4u8, true, Some(t1)),
("url_a".to_string(), 4u8, true, Some(t1)),
];
candidates.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.2.cmp(&a.2))
.then_with(|| b.3.cmp(&a.3))
.then_with(|| a.0.cmp(&b.0))
});
assert_eq!(candidates[0].0, "url_a");
}
#[test]
fn test_duplicate_sorting_full_chain() {
let t1 = chrono::Utc::now();
let t2 = t1 + chrono::Duration::hours(1);
let mut candidates = [
("url_z".to_string(), 2u8, true, Some(t2)), ("url_a".to_string(), 4u8, false, Some(t2)), ("url_b".to_string(), 4u8, true, Some(t1)), ("url_c".to_string(), 4u8, true, Some(t2)), ];
candidates.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.2.cmp(&a.2))
.then_with(|| b.3.cmp(&a.3))
.then_with(|| a.0.cmp(&b.0))
});
assert_eq!(candidates[0].0, "url_c");
assert_eq!(candidates[1].0, "url_b");
assert_eq!(candidates[2].0, "url_a");
assert_eq!(candidates[3].0, "url_z");
}
#[test]
fn test_duplicate_grouping_scopes_by_ref_identity() {
let mut mapping = RepoMapping::default();
mapping.mappings.insert(
"https://github.com/org/repo#thoughts-ref=r-refs~2fheads~2fmain".into(),
RepoLocation {
path: "/tmp/a".into(),
auto_managed: true,
last_sync: None,
},
);
mapping.mappings.insert(
"https://github.com/org/repo#thoughts-ref=r-refs~2ftags~2fv1.0.0".into(),
RepoLocation {
path: "/tmp/b".into(),
auto_managed: true,
last_sync: None,
},
);
let groups = build_identity_groups(&mapping);
assert_eq!(groups.len(), 2);
}
}