From 4592f57c65d72dc2a390a3abf14886f1610e3986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:14:48 -0600 Subject: [PATCH] feat: add `devtrail repair` command to restore broken structure (#12) Add a repair command that detects and fixes missing directories and framework files in a DevTrail installation without affecting user documents. - Checks for missing directories and restores them with .gitkeep - Downloads framework release only once if files need restoration - Restores missing templates, governance docs, config, and manifest - Re-injects directives if DEVTRAIL.md is missing - Recalculates checksums after repair - Reports "healthy" if nothing needs fixing Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/commands/mod.rs | 1 + cli/src/commands/repair.rs | 375 +++++++++++++++++++++++++++++++++++++ cli/src/main.rs | 7 + 3 files changed, 383 insertions(+) create mode 100644 cli/src/commands/repair.rs diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index c090cd7..b7f44fa 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod about; pub mod explore; pub mod init; pub mod remove; +pub mod repair; pub mod status; pub mod update; pub mod update_cli; diff --git a/cli/src/commands/repair.rs b/cli/src/commands/repair.rs new file mode 100644 index 0000000..231f15c --- /dev/null +++ b/cli/src/commands/repair.rs @@ -0,0 +1,375 @@ +use anyhow::{bail, Context, Result}; +use colored::Colorize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::config::Checksums; +use crate::download; +use crate::inject; +use crate::manifest::DistManifest; +use crate::utils; + +/// Expected directories inside .devtrail/ (same as init.rs) +const EXPECTED_DIRS: &[&str] = &[ + ".devtrail/00-governance/exceptions", + ".devtrail/01-requirements", + ".devtrail/02-design/decisions", + ".devtrail/03-implementation", + ".devtrail/04-testing", + ".devtrail/05-operations/incidents", + ".devtrail/05-operations/runbooks", + ".devtrail/06-evolution/technical-debt", + ".devtrail/07-ai-audit/agent-logs", + ".devtrail/07-ai-audit/decisions", + ".devtrail/07-ai-audit/ethical-reviews", +]; + +pub fn run(path: &str) -> Result<()> { + let target = PathBuf::from(path) + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(path)); + + let devtrail_dir = target.join(".devtrail"); + + if !devtrail_dir.exists() { + utils::warn(&format!( + "DevTrail is not installed in {}", + target.display() + )); + utils::info("Run 'devtrail init' to initialize DevTrail in this directory."); + bail!("No DevTrail installation found"); + } + + println!( + "{} DevTrail in {}", + "Repairing".cyan().bold(), + target.display() + ); + + // Phase 1: Check what's missing + let mut missing_dirs: Vec<&str> = Vec::new(); + for dir in EXPECTED_DIRS { + let dir_path = target.join(dir); + if !dir_path.exists() { + missing_dirs.push(dir); + } + } + + // Check for missing framework files that require download + let needs_download = check_needs_download(&target); + + let missing_dir_count = missing_dirs.len(); + let total_issues = missing_dir_count + if needs_download { 1 } else { 0 }; + + if total_issues == 0 { + utils::success("DevTrail structure is healthy, nothing to repair."); + return Ok(()); + } + + println!( + " {} Found {} issue(s) to repair", + "→".blue().bold(), + total_issues + ); + + // Phase 2: Repair missing directories (no download needed) + if !missing_dirs.is_empty() { + utils::info(&format!( + "Restoring {} missing director{}...", + missing_dir_count, + if missing_dir_count == 1 { "y" } else { "ies" } + )); + for dir in &missing_dirs { + let dir_path = target.join(dir); + utils::ensure_dir(&dir_path)?; + let gitkeep = dir_path.join(".gitkeep"); + if !gitkeep.exists() { + std::fs::write(&gitkeep, "")?; + } + utils::success(&format!("Restored {dir}/")); + } + } + + // Phase 3: Download framework and restore missing files if needed + if needs_download { + utils::info("Downloading framework to restore missing files..."); + let release = download::get_latest_release()?; + println!( + " {} {}", + "Using version:".dimmed(), + release.tag_name.green() + ); + + let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?; + let zip_path = temp_dir.path().join("devtrail.zip"); + + download::download_zip(&release.zip_url, &zip_path)?; + + restore_missing_files(&zip_path, &target)?; + } + + // Phase 4: Recalculate checksums + utils::info("Updating checksums..."); + let version = load_current_version(&target); + save_checksums(&target, &version)?; + + println!(); + utils::success("DevTrail repaired successfully!"); + println!(); + + Ok(()) +} + +/// Check if any framework files are missing and a download is needed +fn check_needs_download(target: &Path) -> bool { + let checks = [ + ".devtrail/config.yml", + ".devtrail/dist-manifest.yml", + "DEVTRAIL.md", + ]; + + // Check essential files + for file in &checks { + if !target.join(file).exists() { + return true; + } + } + + // Check if templates dir is empty or missing + let templates_dir = target.join(".devtrail/templates"); + if !templates_dir.exists() { + return true; + } + if let Ok(entries) = std::fs::read_dir(&templates_dir) { + if entries + .flatten() + .filter(|e| { + e.path().is_file() + && e.path() + .extension() + .and_then(|ext| ext.to_str()) + == Some("md") + }) + .count() + == 0 + { + return true; + } + } + + // Check governance docs + let governance_dir = target.join(".devtrail/00-governance"); + if governance_dir.exists() { + let has_md_files = std::fs::read_dir(&governance_dir) + .ok() + .map(|entries| { + entries + .flatten() + .any(|e| { + e.path().is_file() + && e.path() + .extension() + .and_then(|ext| ext.to_str()) + == Some("md") + }) + }) + .unwrap_or(false); + if !has_md_files { + return true; + } + } + + false +} + +/// Restore missing files from the release ZIP +fn restore_missing_files(zip_path: &Path, target: &Path) -> Result<()> { + let file = std::fs::File::open(zip_path).context("Failed to open ZIP file")?; + let mut archive = zip::ZipArchive::new(file).context("Failed to read ZIP archive")?; + + // Find prefix in ZIP + let mut prefix = String::new(); + for i in 0..archive.len() { + let entry = archive.by_index(i)?; + let name = entry.name().to_string(); + if name.ends_with("dist-manifest.yml") { + if let Some(pos) = name.find("dist-manifest.yml") { + prefix = name[..pos].to_string(); + } + break; + } + } + + // Read manifest from ZIP + let mut manifest_content = None; + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + if entry.name().ends_with("dist-manifest.yml") { + let mut content = String::new(); + std::io::Read::read_to_string(&mut entry, &mut content)?; + manifest_content = Some(content); + break; + } + } + + let manifest_str = manifest_content.context("dist-manifest.yml not found in release ZIP")?; + let manifest = DistManifest::from_str(&manifest_str)?; + + let mut restored = 0; + + // Extract only files that are missing + for pattern in &manifest.files { + restored += extract_missing_files(&mut archive, &prefix, pattern, target)?; + } + + // Restore injections if DEVTRAIL.md or directive files are missing + let devtrail_md = target.join("DEVTRAIL.md"); + if !devtrail_md.exists() { + // Read templates for injection + let mut templates: HashMap = HashMap::new(); + for injection in &manifest.injections { + let zip_entry_name = format!("{}{}", prefix, injection.template); + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + if entry.name() == zip_entry_name { + let mut content = String::new(); + std::io::Read::read_to_string(&mut entry, &mut content)?; + templates.insert(injection.template.clone(), content); + break; + } + } + } + + // Re-inject directives + for injection in &manifest.injections { + let template_content = match templates.get(&injection.template) { + Some(content) => content, + None => continue, + }; + + let embed_content = if let Some(embed_file) = &injection.embed { + let embed_path = target.join(embed_file); + if embed_path.exists() { + Some(std::fs::read_to_string(&embed_path)?) + } else { + continue; + } + } else { + None + }; + + let target_path = target.join(&injection.target); + inject::inject_directive(&target_path, template_content, embed_content.as_deref())?; + utils::success(&format!("Restored {}", injection.target)); + restored += 1; + } + } + + // Save manifest + let manifest_path = target.join(".devtrail/dist-manifest.yml"); + if !manifest_path.exists() { + let content = manifest.to_yaml()?; + std::fs::write(&manifest_path, content)?; + utils::success("Restored dist-manifest.yml"); + restored += 1; + } + + if restored > 0 { + utils::info(&format!("Restored {} file(s) from framework", restored)); + } + + Ok(()) +} + +/// Extract files from ZIP that don't exist in the target +fn extract_missing_files( + archive: &mut zip::ZipArchive, + prefix: &str, + pattern: &str, + target: &Path, +) -> Result { + let pattern_with_prefix = format!("{}{}", prefix, pattern); + let mut count = 0; + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let name = entry.name().to_string(); + + let matches = if pattern.ends_with('/') { + name.starts_with(&pattern_with_prefix) + } else { + name == pattern_with_prefix + }; + + if matches && !entry.is_dir() { + let relative = &name[prefix.len()..]; + let dest = target.join(relative); + + // Only restore if the file doesn't already exist + if !dest.exists() { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let mut outfile = std::fs::File::create(&dest)?; + std::io::copy(&mut entry, &mut outfile)?; + utils::success(&format!("Restored {relative}")); + count += 1; + } + } + } + + Ok(count) +} + +fn load_current_version(target: &Path) -> String { + let manifest_path = target.join(".devtrail/dist-manifest.yml"); + match DistManifest::load(&manifest_path) { + Ok(m) => m.version, + Err(_) => "unknown".to_string(), + } +} + +fn save_checksums(target: &Path, version: &str) -> Result<()> { + let mut checksums = Checksums { + version: version.to_string(), + files: std::collections::HashMap::new(), + }; + + if let Ok(entries) = walkdir(target.join(".devtrail")) { + for entry in entries { + if let Some(hash) = utils::file_hash(&entry) { + let relative = entry + .strip_prefix(target) + .unwrap_or(&entry) + .display() + .to_string(); + checksums.files.insert(relative, hash); + } + } + } + + let devtrail_path = target.join("DEVTRAIL.md"); + if let Some(hash) = utils::file_hash(&devtrail_path) { + checksums.files.insert("DEVTRAIL.md".to_string(), hash); + } + + checksums.save(target)?; + Ok(()) +} + +fn walkdir(dir: PathBuf) -> Result> { + let mut files = Vec::new(); + if !dir.is_dir() { + return Ok(files); + } + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + files.extend(walkdir(path)?); + } else { + files.push(path); + } + } + Ok(files) +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 088c4a1..e82a9a1 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -47,6 +47,12 @@ enum Commands { #[arg(default_value = ".")] path: String, }, + /// Repair DevTrail structure by restoring missing directories and files + Repair { + /// Target directory (default: current directory) + #[arg(default_value = ".")] + path: String, + }, /// Show version, author, and license information About, /// Explore DevTrail documentation interactively @@ -71,6 +77,7 @@ fn main() { Commands::UpdateCli => commands::update_cli::run(), Commands::Remove { full } => commands::remove::run(full), Commands::Status { path } => commands::status::run(&path), + Commands::Repair { path } => commands::repair::run(&path), Commands::About => commands::about::run(), #[cfg(feature = "tui")] Commands::Explore { path } => commands::explore::run(&path),