use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::color::{sym_err, sym_ok, sym_warn};
use crate::commands::common::{is_json_output, is_quiet};
use crate::core::{self, Ticket};
use crate::findings;
pub fn run(path: Option<&str>, fix: bool, strict: bool) -> Result<i32> {
if fix {
eprintln!(
" {} --fix is not yet implemented for doctor (use `tkt validate --fix` per project)",
sym_warn()
);
}
match path {
None => run_single_project(fix, strict),
Some(p) => run_cross_project(Path::new(p), fix, strict),
}
}
fn run_single_project(fix: bool, strict: bool) -> Result<i32> {
let mut issues = 0;
match std::process::Command::new("git")
.args(["--version"])
.output()
{
Ok(out) if out.status.success() => {
let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
println!(" {} {}", sym_ok(), ver);
}
_ => {
println!(" {} git not found on PATH", sym_err());
issues += 1;
}
}
let repo_root = match crate::git::repo_root_cwd() {
Ok(r) => {
println!(" {} git repo: {}", sym_ok(), r.display());
Some(r)
}
Err(_) => {
println!(" {} not inside a git repository", sym_err());
issues += 1;
None
}
};
let Some(root) = repo_root else {
println!("\n{} issues found (not in a git repo)", issues);
return Ok(1);
};
let tickets_dir = root.join(".tickets");
if tickets_dir.exists() {
let count = ticket_file_count(&tickets_dir);
println!(" {} .tickets/ exists ({} tickets)", sym_ok(), count);
} else {
println!(" {} .tickets/ not found — run: tkt init", sym_err());
issues += 1;
println!("\n{} issues found", issues);
return Ok(1);
}
let config_path = tickets_dir.join("config.toml");
if config_path.exists() {
println!(" {} .tickets/config.toml present", sym_ok());
} else {
println!(
" {} .tickets/config.toml missing — run: tkt init",
sym_warn()
);
}
match crate::git::has_remote(&root) {
Ok(true) => println!(" {} remote configured", sym_ok()),
_ => println!(
" {} no remote configured (push will be skipped)",
sym_warn()
),
}
match core::load_corpus(&tickets_dir) {
Ok(corpus) => {
let all_findings = run_validate_checks(&corpus, &tickets_dir, &root, fix);
let errors = all_findings
.iter()
.filter(|f| f.severity == "error")
.count();
let warnings = all_findings
.iter()
.filter(|f| f.severity == "warning")
.count();
let effective_errors = if strict { errors + warnings } else { errors };
if effective_errors == 0 && warnings == 0 {
println!(" {} no validation issues", sym_ok());
} else {
if errors > 0 {
println!(" {} {} validation errors", sym_err(), errors);
issues += errors;
}
if warnings > 0 {
if strict {
println!(
" {} {} validation warnings (strict: treated as errors)",
sym_err(),
warnings
);
issues += warnings;
} else {
println!(" {} {} validation warnings", sym_warn(), warnings);
}
}
}
}
Err(e) => {
println!(" {} failed to load corpus: {}", sym_err(), e);
issues += 1;
}
}
if !is_quiet() {
println!();
if issues == 0 {
println!("All checks passed.");
} else {
println!("{} issue(s) found.", issues);
}
}
Ok(if issues == 0 { 0 } else { 1 })
}
fn run_cross_project(scan_path: &Path, fix: bool, strict: bool) -> Result<i32> {
let projects = find_ticket_dirs(scan_path);
let non_tkt = find_non_tkt_git_repos(scan_path, &projects);
let json_mode = is_json_output();
if projects.is_empty() && non_tkt.is_empty() {
if json_mode {
} else {
println!("No projects found under {}", scan_path.display());
}
return Ok(0);
}
if !json_mode && !is_quiet() {
println!(
"Scanning {} ({} tkt projects, {} non-tkt repos)\n",
scan_path.display(),
projects.len(),
non_tkt.len()
);
}
let mut clean = 0;
let mut fixable = 0;
let mut broken = 0;
for tickets_dir in &projects {
let project_path = tickets_dir.parent().unwrap_or(tickets_dir);
let project_name = project_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let count = ticket_file_count(tickets_dir);
match core::load_corpus(tickets_dir) {
Ok(corpus) => {
let repo_root = tickets_dir.parent().unwrap_or(tickets_dir);
let all_findings = run_validate_checks(&corpus, tickets_dir, repo_root, fix);
let errors = all_findings
.iter()
.filter(|f| f.severity == "error")
.count();
let warnings = all_findings
.iter()
.filter(|f| f.severity == "warning")
.count();
let effective_errors = if strict { errors + warnings } else { errors };
if json_mode {
println!(
"{{\"path\":{},\"status\":\"{}\",\"tickets\":{},\"errors\":{},\"warnings\":{}}}",
serde_json::to_string(&project_path.display().to_string()).unwrap_or_default(),
if effective_errors > 0 { "fail" } else { "pass" },
count,
errors,
warnings
);
} else if effective_errors == 0 && warnings == 0 {
println!(" {} {} ({} tickets)", sym_ok(), project_name, count);
clean += 1;
} else if effective_errors == 0 {
println!(
" {} {} ({} tickets, {} warnings)",
sym_warn(),
project_name,
count,
warnings
);
fixable += 1;
} else {
println!(
" {} {} ({} tickets, {} errors)",
sym_err(),
project_name,
count,
effective_errors
);
broken += 1;
}
}
Err(_) => {
if json_mode {
println!(
"{{\"path\":{},\"status\":\"parse_error\",\"tickets\":{}}}",
serde_json::to_string(&project_path.display().to_string())
.unwrap_or_default(),
count
);
} else {
println!(
" {} {} ({} tickets, parse errors)",
sym_err(),
project_name,
count
);
}
broken += 1;
}
}
}
for repo_path in &non_tkt {
let name = repo_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
if json_mode {
println!(
"{{\"path\":{},\"status\":\"no_tickets\",\"is_git\":true}}",
serde_json::to_string(&repo_path.display().to_string()).unwrap_or_default()
);
} else {
println!(" · {} (git repo, no .tickets/)", name);
}
}
if !json_mode && !is_quiet() {
println!(
"\nSummary: {} clean, {} fixable, {} broken, {} not using tkt",
clean,
fixable,
broken,
non_tkt.len()
);
}
Ok(if broken > 0 { 1 } else { 0 })
}
fn ticket_file_count(dir: &Path) -> usize {
std::fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
.count()
})
.unwrap_or(0)
}
fn find_ticket_dirs(root: &Path) -> Vec<PathBuf> {
let mut results = Vec::new();
find_ticket_dirs_recursive(root, &mut results, 5);
results.sort();
results
}
fn find_ticket_dirs_recursive(dir: &Path, results: &mut Vec<PathBuf>, depth: u8) {
if depth == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') && name_str != ".tickets" {
continue;
}
if matches!(
name_str.as_ref(),
"node_modules" | "target" | "dist" | "build" | ".git" | "vendor"
) {
continue;
}
if name_str == ".tickets" {
results.push(path);
} else {
find_ticket_dirs_recursive(&path, results, depth - 1);
}
}
}
fn find_non_tkt_git_repos(scan_path: &Path, tkt_dirs: &[PathBuf]) -> Vec<PathBuf> {
let tkt_parents: std::collections::HashSet<PathBuf> = tkt_dirs
.iter()
.filter_map(|d| d.parent().map(|p| p.to_path_buf()))
.collect();
let mut non_tkt = Vec::new();
let Ok(entries) = std::fs::read_dir(scan_path) else {
return non_tkt;
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name_str = entry.file_name().to_string_lossy().to_string();
if name_str.starts_with('.') {
continue;
}
if path.join(".git").exists() && !tkt_parents.contains(&path) {
non_tkt.push(path);
}
}
non_tkt.sort();
non_tkt
}
fn run_validate_checks(
corpus: &[Ticket],
_tickets_dir: &Path,
_repo_root: &Path,
_fix: bool,
) -> Vec<findings::Finding> {
let mut all = Vec::new();
all.extend(findings::check_status(corpus));
all.extend(findings::check_env(corpus));
all.extend(findings::check_id_filename(corpus));
all.extend(findings::check_duplicate_ids(corpus));
all.extend(findings::check_dangling_deps(corpus));
all.extend(findings::check_cycles(corpus));
all
}