diff --git a/.claude/skills/final-review/SKILL.md b/.claude/skills/final-review/SKILL.md index 47bd846..e79883a 100644 --- a/.claude/skills/final-review/SKILL.md +++ b/.claude/skills/final-review/SKILL.md @@ -11,13 +11,15 @@ Pre-merge review: `/final-review` ## Process -### 0. Fetch Latest +### 0. Fetch Latest and Identify Changes Run `git fetch origin main` to ensure comparisons use the latest main branch. +**IMPORTANT:** Always use `origin/main` (not `main`) for all diff comparisons to ensure you're comparing against the actual remote state, not a potentially stale local branch. + ### 1. Test Coverage -- Run `git diff main --name-only` to identify changed files +- Run `git diff origin/main --name-only` to identify changed files - Confirm each core module (`src/*.rs` excluding test modules) has corresponding tests - Current modules requiring tests: `loader.rs`, `executor.rs`, `state.rs` - Note: `main.rs`, `lib.rs`, `templates.rs`, and `src/commands/` do not require separate unit tests @@ -57,7 +59,9 @@ Check for: ### 4. Version Update -Check `Cargo.toml` version against change scope: +Check if `Cargo.toml` version changed in this PR using `git diff origin/main -- Cargo.toml`. + +Evaluate version against change scope: - **Major:** Breaking changes (removed features, incompatible API changes) - **Minor:** New features (new CLI commands, new public API functions) @@ -78,8 +82,8 @@ To trigger a release, simply bump the version in `Cargo.toml` before merging. ### 5. PR Metadata (if PR exists) - `gh pr view` - check current title/description -- `git log main..HEAD --oneline` - see commits -- `git diff main --stat` - see change scope +- `git log origin/main..HEAD --oneline` - see commits +- `git diff origin/main --stat` - see change scope **Fix:** Use `gh pr edit --title` and `gh pr edit --body` to update. diff --git a/CLAUDE.md b/CLAUDE.md index 028a013..b247ad9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,10 +70,10 @@ await fs.writeFile(`${projectRoot}/config.json`, '{}'); - `src/lib.rs` - Core types and public API - `src/loader.rs` - Migration discovery - `src/executor.rs` - Subprocess execution -- `src/state.rs` - History tracking (`.history` file) +- `src/state.rs` - History tracking (consolidated `history` file with applied migrations and baseline) - `src/version.rs` - Base36 version generation and parsing - `src/templates.rs` - Embedded migration templates -- `src/baseline.rs` - Baseline management (`.baseline` file) +- `src/baseline.rs` - Baseline validation and file deletion - `src/commands/` - CLI command implementations - `mod.rs` - Command module exports - `status.rs` - Status command (shows version summary) diff --git a/Cargo.lock b/Cargo.lock index ac1d16b..de4e64c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "migrate" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 7a9fa50..fbe13a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "migrate" -version = "0.4.0" +version = "0.4.1" edition = "2021" description = "Generic file migration tool for applying ordered transformations to a project directory" license = "MIT" diff --git a/README.md b/README.md index fae5c3c..c9640c0 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ await fs.writeFile( ### 3. Applying Migrations -Run `migrate up` to apply all pending migrations in order. Each successful migration is recorded in `.history`, so it won't run again. +Run `migrate up` to apply all pending migrations in order. Each successful migration is recorded in `history`, so it won't run again. ```bash migrate up # Apply all pending @@ -175,7 +175,7 @@ migrate up --baseline --keep # Apply and baseline without deleting files - You want to reduce clutter in the migrations directory **What baselining does:** -- Creates/updates `.baseline` file with the baseline version +- Records the baseline version in the `history` file - Optionally deletes migration files at or before that version - Future `migrate up` skips migrations covered by the baseline @@ -184,8 +184,7 @@ migrate up --baseline --keep # Apply and baseline without deleting files ``` your-project/ ├── migrations/ -│ ├── .history # Tracks applied migrations (auto-generated) -│ ├── .baseline # Baseline marker (optional, from baselining) +│ ├── history # Tracks applied migrations and baseline (auto-generated) │ ├── 1fc2h-add-prettier.sh │ └── 1fc3h-configure-ci.ts └── ... diff --git a/src/baseline.rs b/src/baseline.rs index cc55d1e..0a7f442 100644 --- a/src/baseline.rs +++ b/src/baseline.rs @@ -1,125 +1,8 @@ use anyhow::{bail, Context, Result}; -use chrono::{DateTime, Utc}; use std::fs; -use std::path::Path; -const BASELINE_FILE: &str = ".baseline"; - -/// A baseline assertion: migrations with version <= this are no longer required as files -#[derive(Debug, Clone)] -pub struct Baseline { - /// Version string (e.g., "1fb2g") - pub version: String, - /// When the baseline was created - pub created: DateTime, - /// Optional description of what migrations are included - pub summary: Option, -} - -/// Read the baseline file if it exists. -pub fn read_baseline(migrations_dir: &Path) -> Result> { - let baseline_path = migrations_dir.join(BASELINE_FILE); - - if !baseline_path.exists() { - return Ok(None); - } - - let content = fs::read_to_string(&baseline_path) - .with_context(|| format!("Failed to read baseline file: {}", baseline_path.display()))?; - - parse_baseline(&content).map(Some) -} - -/// Write the baseline file. -pub fn write_baseline(migrations_dir: &Path, baseline: &Baseline) -> Result<()> { - let baseline_path = migrations_dir.join(BASELINE_FILE); - - let mut content = format!( - "version: {}\ncreated: {}\n", - baseline.version, - baseline.created.to_rfc3339() - ); - - if let Some(summary) = &baseline.summary { - content.push_str("summary: |\n"); - for line in summary.lines() { - content.push_str(" "); - content.push_str(line); - content.push('\n'); - } - } - - fs::write(&baseline_path, content) - .with_context(|| format!("Failed to write baseline file: {}", baseline_path.display()))?; - - Ok(()) -} - -/// Parse baseline file content into a Baseline struct. -fn parse_baseline(content: &str) -> Result { - let mut version: Option = None; - let mut created: Option> = None; - let mut summary: Option = None; - let mut in_summary = false; - let mut summary_lines: Vec = Vec::new(); - - for line in content.lines() { - if in_summary { - // Summary lines are indented with 2 spaces - if let Some(stripped) = line.strip_prefix(" ") { - summary_lines.push(stripped.to_string()); - continue; - } else if line.starts_with(' ') || line.is_empty() { - // Still in summary block - if line.is_empty() { - summary_lines.push(String::new()); - } else { - summary_lines.push(line.trim_start().to_string()); - } - continue; - } else { - // End of summary block - in_summary = false; - summary = Some(summary_lines.join("\n").trim_end().to_string()); - summary_lines.clear(); - } - } - - if let Some(stripped) = line.strip_prefix("version:") { - version = Some(stripped.trim().to_string()); - } else if let Some(stripped) = line.strip_prefix("created:") { - let timestamp_str = stripped.trim(); - created = Some( - DateTime::parse_from_rfc3339(timestamp_str) - .with_context(|| format!("Invalid timestamp in baseline: {}", timestamp_str))? - .with_timezone(&Utc), - ); - } else if let Some(stripped) = line.strip_prefix("summary:") { - let rest = stripped.trim(); - if rest == "|" { - // Multi-line summary - in_summary = true; - } else if !rest.is_empty() { - // Single-line summary - summary = Some(rest.to_string()); - } - } - } - - // Handle summary at end of file - if in_summary && !summary_lines.is_empty() { - summary = Some(summary_lines.join("\n").trim_end().to_string()); - } - - let version = version.context("Baseline file missing 'version' field")?; - let created = created.context("Baseline file missing 'created' field")?; - - Ok(Baseline { - version, - created, - summary, - }) -} +use crate::state::Baseline; +use crate::{AppliedMigration, Migration}; /// Compare two version strings. Returns true if v1 <= v2. pub fn version_lte(v1: &str, v2: &str) -> bool { @@ -130,7 +13,7 @@ pub fn version_lte(v1: &str, v2: &str) -> bool { /// Returns the list of deleted file paths. pub fn delete_baselined_migrations( baseline_version: &str, - available: &[crate::Migration], + available: &[Migration], ) -> Result> { let mut deleted = Vec::new(); @@ -153,8 +36,8 @@ pub fn delete_baselined_migrations( /// Returns an error if validation fails. pub fn validate_baseline( version: &str, - available: &[crate::Migration], - applied: &[crate::AppliedMigration], + available: &[Migration], + applied: &[AppliedMigration], existing_baseline: Option<&Baseline>, ) -> Result<()> { // Check if the version matches any migration @@ -194,41 +77,9 @@ pub fn validate_baseline( #[cfg(test)] mod tests { use super::*; - use crate::{AppliedMigration, Migration}; + use chrono::Utc; use std::path::PathBuf; - #[test] - fn test_parse_baseline_simple() { - let content = "version: 1fb2g\ncreated: 2024-06-15T14:30:00Z\n"; - let baseline = parse_baseline(content).unwrap(); - assert_eq!(baseline.version, "1fb2g"); - assert!(baseline.summary.is_none()); - } - - #[test] - fn test_parse_baseline_with_summary() { - let content = r#"version: 1fb2g -created: 2024-06-15T14:30:00Z -summary: | - Initial project setup - TypeScript config -"#; - let baseline = parse_baseline(content).unwrap(); - assert_eq!(baseline.version, "1fb2g"); - assert_eq!( - baseline.summary, - Some("Initial project setup\nTypeScript config".to_string()) - ); - } - - #[test] - fn test_parse_baseline_single_line_summary() { - let content = "version: 1fb2g\ncreated: 2024-06-15T14:30:00Z\nsummary: Initial setup\n"; - let baseline = parse_baseline(content).unwrap(); - assert_eq!(baseline.version, "1fb2g"); - assert_eq!(baseline.summary, Some("Initial setup".to_string())); - } - #[test] fn test_version_lte() { assert!(version_lte("1f700", "1f700")); diff --git a/src/commands/baseline.rs b/src/commands/baseline.rs index 83398e4..bb5ebb4 100644 --- a/src/commands/baseline.rs +++ b/src/commands/baseline.rs @@ -2,12 +2,9 @@ use anyhow::Result; use chrono::Utc; use std::path::Path; -use crate::baseline::{ - delete_baselined_migrations, read_baseline, validate_baseline, write_baseline, Baseline, -}; - +use crate::baseline::{delete_baselined_migrations, validate_baseline}; use crate::loader::discover_migrations; -use crate::state::read_history; +use crate::state::{append_baseline, read_history, Baseline}; /// Create a baseline at the specified version pub fn run( @@ -33,11 +30,10 @@ pub fn run( } let available = discover_migrations(&migrations_path)?; - let applied = read_history(&migrations_path)?; - let existing_baseline = read_baseline(&migrations_path)?; + let state = read_history(&migrations_path)?; // Validate the baseline - validate_baseline(version, &available, &applied, existing_baseline.as_ref())?; + validate_baseline(version, &available, &state.applied, state.baseline.as_ref())?; // Find migrations that would be deleted let to_delete: Vec<_> = available @@ -82,8 +78,8 @@ pub fn run( summary: summary.map(|s| s.to_string()), }; - write_baseline(&migrations_path, &baseline)?; - println!("Created .baseline file"); + append_baseline(&migrations_path, &baseline)?; + println!("Added baseline to history file"); // Delete old migration files unless --keep was specified if !keep && !to_delete.is_empty() { diff --git a/src/commands/status.rs b/src/commands/status.rs index 9822dcd..4312e7b 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -1,7 +1,6 @@ use anyhow::Result; use std::path::Path; -use crate::baseline::read_baseline; use crate::loader::discover_migrations; use crate::state::{get_current_version, get_pending, get_target_version, read_history}; @@ -22,16 +21,15 @@ pub fn run(project_root: &Path, migrations_dir: &Path) -> Result<()> { } let available = discover_migrations(&migrations_path)?; - let applied = read_history(&migrations_path)?; - let baseline = read_baseline(&migrations_path)?; - let pending = get_pending(&available, &applied, baseline.as_ref()); + let state = read_history(&migrations_path)?; + let pending = get_pending(&available, &state); - if available.is_empty() && baseline.is_none() { + if available.is_empty() && state.baseline.is_none() { println!("No migrations found in: {}", migrations_path.display()); return Ok(()); } - let current_version = get_current_version(&available, &applied); + let current_version = get_current_version(&available, &state.applied); let target_version = get_target_version(&available); println!("Migration Status"); @@ -39,7 +37,7 @@ pub fn run(project_root: &Path, migrations_dir: &Path) -> Result<()> { println!(); // Show baseline info if present - if let Some(ref b) = baseline { + if let Some(ref b) = state.baseline { println!("Baseline: {} ({})", b.version, b.created.format("%Y-%m-%d")); if let Some(ref summary) = b.summary { for line in summary.lines() { @@ -51,10 +49,10 @@ pub fn run(project_root: &Path, migrations_dir: &Path) -> Result<()> { // Show version summary line match (¤t_version, &target_version) { - (None, Some(target)) if baseline.is_some() => { + (None, Some(target)) if state.baseline.is_some() => { println!( "Version: {} -> {} ({} pending)", - baseline.as_ref().unwrap().version, + state.baseline.as_ref().unwrap().version, target, pending.len() ); @@ -73,10 +71,10 @@ pub fn run(project_root: &Path, migrations_dir: &Path) -> Result<()> { pending.len() ); } - (None, None) if baseline.is_some() => { + (None, None) if state.baseline.is_some() => { println!( "Version: {} (up to date, baselined)", - baseline.as_ref().unwrap().version + state.baseline.as_ref().unwrap().version ); } _ => {} @@ -84,11 +82,12 @@ pub fn run(project_root: &Path, migrations_dir: &Path) -> Result<()> { println!(); // Show applied migrations - if !applied.is_empty() { - println!("Applied ({}):", applied.len()); - for migration in &applied { + if !state.applied.is_empty() { + println!("Applied ({}):", state.applied.len()); + for migration in &state.applied { // Check if this migration is at or before baseline - let is_baselined = baseline + let is_baselined = state + .baseline .as_ref() .is_some_and(|b| extract_version(&migration.id) <= Some(b.version.clone())); diff --git a/src/commands/up.rs b/src/commands/up.rs index 7811c90..72929e3 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -2,10 +2,10 @@ use anyhow::Result; use chrono::Utc; use std::path::Path; -use crate::baseline::{delete_baselined_migrations, read_baseline, write_baseline, Baseline}; +use crate::baseline::delete_baselined_migrations; use crate::executor::execute; use crate::loader::discover_migrations; -use crate::state::{append_history, get_pending, read_history}; +use crate::state::{append_baseline, append_history, get_pending, read_history, Baseline}; use crate::ExecutionContext; /// Apply all pending migrations @@ -37,9 +37,8 @@ pub fn run( } let available = discover_migrations(&migrations_path)?; - let applied = read_history(&migrations_path)?; - let baseline = read_baseline(&migrations_path)?; - let pending = get_pending(&available, &applied, baseline.as_ref()); + let state = read_history(&migrations_path)?; + let pending = get_pending(&available, &state); if pending.is_empty() { println!("No pending migrations."); @@ -116,7 +115,7 @@ pub fn run( summary: None, }; - write_baseline(&migrations_path, &new_baseline)?; + append_baseline(&migrations_path, &new_baseline)?; println!("Created baseline at version '{}'", version); if !keep { diff --git a/src/state.rs b/src/state.rs index e1ee5a9..c609fcd 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,17 +4,44 @@ use std::fs::{self, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::Path; -use crate::baseline::Baseline; use crate::{AppliedMigration, Migration}; -const HISTORY_FILE: &str = ".history"; +const HISTORY_FILE: &str = "history"; +const LEGACY_HISTORY_FILE: &str = ".history"; +const LEGACY_BASELINE_FILE: &str = ".baseline"; + +/// A baseline assertion: migrations with version <= this are considered applied +#[derive(Debug, Clone)] +pub struct Baseline { + /// Version string (e.g., "1fb2g") + pub version: String, + /// When the baseline was created + pub created: DateTime, + /// Optional description of what migrations are included + pub summary: Option, +} + +/// State read from the history file +#[derive(Debug, Default)] +pub struct HistoryState { + pub applied: Vec, + pub baseline: Option, +} -/// Read the history file and return all applied migrations. -pub fn read_history(migrations_dir: &Path) -> Result> { +/// Read the history file and return state (applied migrations and baseline). +/// Handles migration from legacy .history and .baseline files. +pub fn read_history(migrations_dir: &Path) -> Result { let history_path = migrations_dir.join(HISTORY_FILE); + let legacy_history_path = migrations_dir.join(LEGACY_HISTORY_FILE); + let legacy_baseline_path = migrations_dir.join(LEGACY_BASELINE_FILE); + + // Migrate legacy files if needed + if !history_path.exists() && (legacy_history_path.exists() || legacy_baseline_path.exists()) { + migrate_legacy_files(migrations_dir)?; + } if !history_path.exists() { - return Ok(Vec::new()); + return Ok(HistoryState::default()); } let file = fs::File::open(&history_path) @@ -22,6 +49,7 @@ pub fn read_history(migrations_dir: &Path) -> Result> { let reader = BufReader::new(file); let mut applied = Vec::new(); + let mut baseline: Option = None; for line in reader.lines() { let line = line.context("Failed to read line from history file")?; @@ -31,7 +59,29 @@ pub fn read_history(migrations_dir: &Path) -> Result> { continue; } - // Format: "id timestamp" (space-separated) + // Baseline format: "baseline: version timestamp [summary]" + if let Some(rest) = line.strip_prefix("baseline: ") { + let parts: Vec<&str> = rest.splitn(3, ' ').collect(); + if parts.len() >= 2 { + let version = parts[0].to_string(); + let created = DateTime::parse_from_rfc3339(parts[1]) + .with_context(|| format!("Invalid timestamp in baseline: {}", parts[1]))? + .with_timezone(&Utc); + let summary = if parts.len() == 3 { + Some(parts[2].to_string()) + } else { + None + }; + baseline = Some(Baseline { + version, + created, + summary, + }); + } + continue; + } + + // Migration format: "id timestamp" (space-separated) let parts: Vec<&str> = line.splitn(2, ' ').collect(); if parts.len() != 2 { continue; @@ -45,7 +95,143 @@ pub fn read_history(migrations_dir: &Path) -> Result> { applied.push(AppliedMigration { id, applied_at }); } - Ok(applied) + // Also check for legacy .baseline file that might not have been migrated + if baseline.is_none() && legacy_baseline_path.exists() { + if let Some(legacy_baseline) = read_legacy_baseline(&legacy_baseline_path)? { + // Write it to the new history file and delete the legacy file + append_baseline(migrations_dir, &legacy_baseline)?; + fs::remove_file(&legacy_baseline_path).ok(); + baseline = Some(legacy_baseline); + } + } + + Ok(HistoryState { applied, baseline }) +} + +/// Migrate legacy .history and .baseline files to the new history file format. +fn migrate_legacy_files(migrations_dir: &Path) -> Result<()> { + let history_path = migrations_dir.join(HISTORY_FILE); + let legacy_history_path = migrations_dir.join(LEGACY_HISTORY_FILE); + let legacy_baseline_path = migrations_dir.join(LEGACY_BASELINE_FILE); + + // Read legacy history + let mut content = String::new(); + if legacy_history_path.exists() { + content = fs::read_to_string(&legacy_history_path).with_context(|| { + format!( + "Failed to read legacy history: {}", + legacy_history_path.display() + ) + })?; + } + + // Read and append legacy baseline + if legacy_baseline_path.exists() { + if let Some(baseline) = read_legacy_baseline(&legacy_baseline_path)? { + let baseline_line = format_baseline_line(&baseline); + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(&baseline_line); + content.push('\n'); + } + } + + // Write new history file + if !content.is_empty() { + fs::write(&history_path, &content) + .with_context(|| format!("Failed to write history file: {}", history_path.display()))?; + } + + // Remove legacy files + if legacy_history_path.exists() { + fs::remove_file(&legacy_history_path).ok(); + } + if legacy_baseline_path.exists() { + fs::remove_file(&legacy_baseline_path).ok(); + } + + Ok(()) +} + +/// Read a legacy .baseline file (YAML-like format) +fn read_legacy_baseline(path: &Path) -> Result> { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read baseline file: {}", path.display()))?; + + let mut version: Option = None; + let mut created: Option> = None; + let mut summary: Option = None; + let mut in_summary = false; + let mut summary_lines: Vec = Vec::new(); + + for line in content.lines() { + if in_summary { + if let Some(stripped) = line.strip_prefix(" ") { + summary_lines.push(stripped.to_string()); + continue; + } else if line.starts_with(' ') || line.is_empty() { + if line.is_empty() { + summary_lines.push(String::new()); + } else { + summary_lines.push(line.trim_start().to_string()); + } + continue; + } else { + in_summary = false; + summary = Some(summary_lines.join("\n").trim_end().to_string()); + summary_lines.clear(); + } + } + + if let Some(stripped) = line.strip_prefix("version:") { + version = Some(stripped.trim().to_string()); + } else if let Some(stripped) = line.strip_prefix("created:") { + let timestamp_str = stripped.trim(); + created = Some( + DateTime::parse_from_rfc3339(timestamp_str) + .with_context(|| format!("Invalid timestamp in baseline: {}", timestamp_str))? + .with_timezone(&Utc), + ); + } else if let Some(stripped) = line.strip_prefix("summary:") { + let rest = stripped.trim(); + if rest == "|" { + in_summary = true; + } else if !rest.is_empty() { + summary = Some(rest.to_string()); + } + } + } + + if in_summary && !summary_lines.is_empty() { + summary = Some(summary_lines.join("\n").trim_end().to_string()); + } + + match (version, created) { + (Some(version), Some(created)) => Ok(Some(Baseline { + version, + created, + summary, + })), + _ => Ok(None), + } +} + +/// Format a baseline as a single line for the history file +fn format_baseline_line(baseline: &Baseline) -> String { + match &baseline.summary { + Some(summary) => format!( + "baseline: {} {} {}", + baseline.version, + baseline.created.to_rfc3339(), + summary.replace('\n', " ") + ), + None => format!( + "baseline: {} {}", + baseline.version, + baseline.created.to_rfc3339() + ), + } } /// Append a migration record to the history file. @@ -64,15 +250,27 @@ pub fn append_history(migrations_dir: &Path, id: &str, applied_at: DateTime Ok(()) } +/// Append a baseline record to the history file. +pub fn append_baseline(migrations_dir: &Path, baseline: &Baseline) -> Result<()> { + let history_path = migrations_dir.join(HISTORY_FILE); + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&history_path) + .with_context(|| format!("Failed to open history file: {}", history_path.display()))?; + + writeln!(file, "{}", format_baseline_line(baseline)) + .context("Failed to write baseline to history file")?; + + Ok(()) +} + /// Get pending migrations (available but not yet applied). /// If a baseline is provided, skip migrations at or before the baseline version. -pub fn get_pending<'a>( - available: &'a [Migration], - applied: &[AppliedMigration], - baseline: Option<&Baseline>, -) -> Vec<&'a Migration> { +pub fn get_pending<'a>(available: &'a [Migration], state: &HistoryState) -> Vec<&'a Migration> { let applied_ids: std::collections::HashSet<&str> = - applied.iter().map(|a| a.id.as_str()).collect(); + state.applied.iter().map(|a| a.id.as_str()).collect(); available .iter() @@ -82,7 +280,7 @@ pub fn get_pending<'a>( return false; } // Covered by baseline (only skip if not in history) - if let Some(b) = baseline { + if let Some(b) = &state.baseline { if m.version.as_str() <= b.version.as_str() { return false; } @@ -139,12 +337,15 @@ mod tests { }, ]; - let applied = vec![AppliedMigration { - id: "1f700-first".to_string(), - applied_at: Utc::now(), - }]; + let state = HistoryState { + applied: vec![AppliedMigration { + id: "1f700-first".to_string(), + applied_at: Utc::now(), + }], + baseline: None, + }; - let pending = get_pending(&available, &applied, None); + let pending = get_pending(&available, &state); assert_eq!(pending.len(), 2); assert_eq!(pending[0].id, "1f710-second"); assert_eq!(pending[1].id, "1f720-third"); @@ -171,14 +372,16 @@ mod tests { ]; // No applied migrations, but baseline at 1f710 - let applied: Vec = vec![]; - let baseline = Baseline { - version: "1f710".to_string(), - created: Utc::now(), - summary: None, + let state = HistoryState { + applied: vec![], + baseline: Some(Baseline { + version: "1f710".to_string(), + created: Utc::now(), + summary: None, + }), }; - let pending = get_pending(&available, &applied, Some(&baseline)); + let pending = get_pending(&available, &state); assert_eq!(pending.len(), 1); assert_eq!(pending[0].id, "1f720-third"); } @@ -248,4 +451,56 @@ mod tests { ]; assert_eq!(get_target_version(&available), Some("1f710".to_string())); } + + #[test] + fn test_read_history_migrates_baseline_only() { + // Test that a legacy .baseline file without .history is properly migrated + let temp_dir = tempfile::tempdir().unwrap(); + let migrations_dir = temp_dir.path(); + + // Create only a legacy .baseline file (no .history) + let baseline_content = "version: 1f710\ncreated: 2024-06-15T14:30:00Z\n"; + fs::write(migrations_dir.join(".baseline"), baseline_content).unwrap(); + + // Read history should migrate the baseline + let state = read_history(migrations_dir).unwrap(); + + // Verify baseline was read + assert!(state.baseline.is_some()); + let baseline = state.baseline.unwrap(); + assert_eq!(baseline.version, "1f710"); + + // Verify new history file was created + assert!(migrations_dir.join("history").exists()); + + // Verify legacy .baseline was deleted + assert!(!migrations_dir.join(".baseline").exists()); + } + + #[test] + fn test_format_baseline_line() { + let baseline = Baseline { + version: "1f710".to_string(), + created: DateTime::parse_from_rfc3339("2024-06-15T14:30:00Z") + .unwrap() + .with_timezone(&Utc), + summary: None, + }; + assert_eq!( + format_baseline_line(&baseline), + "baseline: 1f710 2024-06-15T14:30:00+00:00" + ); + + let baseline_with_summary = Baseline { + version: "1f710".to_string(), + created: DateTime::parse_from_rfc3339("2024-06-15T14:30:00Z") + .unwrap() + .with_timezone(&Utc), + summary: Some("Initial setup\nAdded config".to_string()), + }; + assert_eq!( + format_baseline_line(&baseline_with_summary), + "baseline: 1f710 2024-06-15T14:30:00+00:00 Initial setup Added config" + ); + } } diff --git a/tests/fixture_operations.rs b/tests/fixture_operations.rs index 1d13a55..348bbd2 100644 --- a/tests/fixture_operations.rs +++ b/tests/fixture_operations.rs @@ -372,7 +372,7 @@ EOF assert!(temp_dir.path().join("CHANGELOG.md").exists()); // Verify history contains all migrations - let history = fs::read_to_string(temp_dir.path().join("migrations/.history")).unwrap(); + let history = fs::read_to_string(temp_dir.path().join("migrations/history")).unwrap(); assert!(history.contains("00001-bump-version")); assert!(history.contains("00002-add-feature")); assert!(history.contains("00003-create-changelog")); diff --git a/tests/integration.rs b/tests/integration.rs index b5eb33f..6a795b3 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -232,7 +232,7 @@ touch "$MIGRATE_PROJECT_ROOT/created-by-migration.txt" ); // Check history file - let history = migrations_dir.join(".history"); + let history = migrations_dir.join("history"); assert!(history.exists(), "History file should be created"); let history_content = fs::read_to_string(&history).unwrap(); @@ -281,7 +281,7 @@ touch "$MIGRATE_PROJECT_ROOT/should-not-exist.txt" // History should NOT be updated assert!( - !migrations_dir.join(".history").exists(), + !migrations_dir.join("history").exists(), "Dry run should not update history" ); } @@ -347,7 +347,7 @@ touch "$MIGRATE_PROJECT_ROOT/third.txt" assert!(!temp_dir.path().join("third.txt").exists()); // History should only contain first migration - let history = fs::read_to_string(migrations_dir.join(".history")).unwrap(); + let history = fs::read_to_string(migrations_dir.join("history")).unwrap(); assert!(history.contains("00001-success")); assert!(!history.contains("00002-fail")); } @@ -373,7 +373,7 @@ fn test_status_shows_applied_and_pending() { // Write history for first migration only fs::write( - migrations_dir.join(".history"), + migrations_dir.join("history"), "00001-first 2024-01-01T00:00:00+00:00\n", ) .unwrap();