use crate::core::event::{EventLogger, EventType, ForgeEvent};
use crate::core::task::{Task, TaskManager, TaskStatus};
use chrono::Utc;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct ShipResult {
pub changelog: String,
pub archived_tasks: usize,
pub archived_to: Option<PathBuf>,
pub version_suggestion: VersionBump,
pub cleaned: bool,
}
#[derive(Debug)]
pub enum VersionBump {
Major,
Minor,
Patch,
None,
}
impl std::fmt::Display for VersionBump {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VersionBump::Major => write!(f, "major"),
VersionBump::Minor => write!(f, "minor"),
VersionBump::Patch => write!(f, "patch"),
VersionBump::None => write!(f, "none"),
}
}
}
pub fn generate_changelog(forge_dir: &Path) -> anyhow::Result<String> {
let task_mgr = TaskManager::new(forge_dir);
let tasks = task_mgr.list_tasks()?;
let completed: Vec<&Task> = tasks
.iter()
.filter(|t| t.status == TaskStatus::Completed && t.id.starts_with("T-"))
.collect();
if completed.is_empty() {
return Ok("No completed tasks to include in changelog.".to_string());
}
let date = Utc::now().format("%Y-%m-%d");
let mut added = Vec::new();
let mut fixed = Vec::new();
let mut changed = Vec::new();
for task in &completed {
let task_type = task.task_type.as_deref().unwrap_or("");
let entry = format!("- {} ({})", task.title, task.id);
match task_type {
"implement" | "design" | "test" | "document" | "" => added.push(entry),
"fix" => fixed.push(entry),
_ => changed.push(entry),
}
}
let mut changelog = format!("## [Unreleased] - {date}\n\n");
if !added.is_empty() {
changelog.push_str("### Added\n\n");
for entry in &added {
changelog.push_str(entry);
changelog.push('\n');
}
changelog.push('\n');
}
if !fixed.is_empty() {
changelog.push_str("### Fixed\n\n");
for entry in &fixed {
changelog.push_str(entry);
changelog.push('\n');
}
changelog.push('\n');
}
if !changed.is_empty() {
changelog.push_str("### Changed\n\n");
for entry in &changed {
changelog.push_str(entry);
changelog.push('\n');
}
changelog.push('\n');
}
Ok(changelog)
}
pub fn archive_cycle(forge_dir: &Path) -> anyhow::Result<(usize, PathBuf)> {
let timestamp = Utc::now().format("%Y-%m-%d-%H%M");
let archive_dir = forge_dir.join("archive").join(timestamp.to_string());
std::fs::create_dir_all(&archive_dir)?;
let mut archived = 0;
let tasks_dir = forge_dir.join("tasks");
let archive_tasks = archive_dir.join("tasks");
if tasks_dir.exists() {
std::fs::create_dir_all(&archive_tasks)?;
for entry in std::fs::read_dir(&tasks_dir)?.flatten() {
let dest = archive_tasks.join(entry.file_name());
std::fs::rename(entry.path(), dest)?;
archived += 1;
}
}
let events_src = forge_dir.join("events.jsonl");
if events_src.exists() {
std::fs::copy(&events_src, archive_dir.join("events.jsonl"))?;
}
let results_dir = forge_dir.join("results");
let archive_results = archive_dir.join("results");
if results_dir.exists() {
copy_dir_contents(&results_dir, &archive_results)?;
std::fs::remove_dir_all(&results_dir)?;
std::fs::create_dir_all(&results_dir)?;
}
let signals_dir = forge_dir.join("signals");
let archive_signals = archive_dir.join("signals");
if signals_dir.exists() {
copy_dir_contents(&signals_dir, &archive_signals)?;
std::fs::remove_dir_all(&signals_dir)?;
std::fs::create_dir_all(&signals_dir)?;
}
let event_logger = EventLogger::new(forge_dir);
event_logger
.log(&ForgeEvent::new(
EventType::StateReconciled,
format!("Archived {archived} artifacts to {}", archive_dir.display()),
))
.ok();
Ok((archived, archive_dir))
}
pub fn suggest_version_bump(forge_dir: &Path) -> VersionBump {
let task_mgr = TaskManager::new(forge_dir);
let tasks = match task_mgr.list_tasks() {
Ok(t) => t,
Err(_) => return VersionBump::None,
};
let completed: Vec<&Task> = tasks
.iter()
.filter(|t| t.status == TaskStatus::Completed && t.id.starts_with("T-"))
.collect();
if completed.is_empty() {
return VersionBump::None;
}
let has_features = completed.iter().any(|t| {
matches!(
t.task_type.as_deref(),
Some("implement") | Some("design") | None
)
});
let has_only_fixes = completed
.iter()
.all(|t| t.task_type.as_deref() == Some("fix"));
if has_only_fixes {
VersionBump::Patch
} else if has_features {
VersionBump::Minor
} else {
VersionBump::Patch
}
}
pub fn clean_state(forge_dir: &Path) -> anyhow::Result<()> {
use crate::core::state::StateManager;
let state_mgr = StateManager::new(forge_dir);
if let Ok(mut state) = state_mgr.load() {
state.task_summary = Default::default();
state.current_phase = None;
state.active_locks.clear();
state.updated_at = Utc::now();
state_mgr.save(&state)?;
}
let tasks_dir = forge_dir.join("tasks");
if tasks_dir.exists() {
for entry in std::fs::read_dir(&tasks_dir)?.flatten() {
std::fs::remove_file(entry.path()).ok();
}
}
let event_logger = EventLogger::new(forge_dir);
event_logger.log(&ForgeEvent::new(
EventType::StateReconciled,
"Ship phase complete — state cleaned for next cycle",
))?;
Ok(())
}
fn copy_dir_contents(src: &Path, dest: &Path) -> anyhow::Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)?.flatten() {
if entry.path().is_file() {
std::fs::copy(entry.path(), dest.join(entry.file_name()))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::task::{Task, TaskPhase};
fn setup_forge_dir() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let forge_dir = tmp.path().to_path_buf();
std::fs::create_dir_all(forge_dir.join("tasks")).unwrap();
std::fs::create_dir_all(forge_dir.join("results")).unwrap();
std::fs::create_dir_all(forge_dir.join("signals")).unwrap();
(tmp, forge_dir)
}
fn create_completed_task(forge_dir: &Path, id: &str, title: &str, task_type: &str) {
let mgr = TaskManager::new(forge_dir);
let mut task = Task::new(id, title, "Description");
task.status = TaskStatus::Completed;
task.task_type = Some(task_type.to_string());
task.phase = Some(TaskPhase::Build);
mgr.create_task(&task).unwrap();
}
#[test]
fn test_generate_changelog_empty() {
let (_tmp, forge_dir) = setup_forge_dir();
let cl = generate_changelog(&forge_dir).unwrap();
assert!(cl.contains("No completed tasks"));
}
#[test]
fn test_generate_changelog_with_tasks() {
let (_tmp, forge_dir) = setup_forge_dir();
create_completed_task(&forge_dir, "T-001", "Add high score", "implement");
create_completed_task(&forge_dir, "T-002", "Fix combo bug", "fix");
create_completed_task(&forge_dir, "T-003", "Add dark mode", "implement");
let cl = generate_changelog(&forge_dir).unwrap();
assert!(cl.contains("### Added"));
assert!(cl.contains("Add high score (T-001)"));
assert!(cl.contains("Add dark mode (T-003)"));
assert!(cl.contains("### Fixed"));
assert!(cl.contains("Fix combo bug (T-002)"));
}
#[test]
fn test_generate_changelog_skips_non_t_tasks() {
let (_tmp, forge_dir) = setup_forge_dir();
create_completed_task(&forge_dir, "T-001", "Build feature", "implement");
let mgr = TaskManager::new(&forge_dir);
let mut verify = Task::new("V-001", "Verify feature", "desc");
verify.status = TaskStatus::Completed;
verify.phase = Some(TaskPhase::Verify);
mgr.create_task(&verify).unwrap();
let cl = generate_changelog(&forge_dir).unwrap();
assert!(cl.contains("Build feature"));
assert!(!cl.contains("Verify feature"));
}
#[test]
fn test_archive_cycle() {
let (_tmp, forge_dir) = setup_forge_dir();
std::fs::write(forge_dir.join("tasks/T-001.json"), "{}").unwrap();
std::fs::write(forge_dir.join("tasks/T-001.md"), "# T-001").unwrap();
std::fs::write(forge_dir.join("results/T-001.txt"), "output").unwrap();
std::fs::write(forge_dir.join("events.jsonl"), "{}\n").unwrap();
let (count, archive_path) = archive_cycle(&forge_dir).unwrap();
assert_eq!(count, 2); assert!(archive_path.exists());
assert!(archive_path.join("tasks/T-001.json").exists());
assert!(archive_path.join("results/T-001.txt").exists());
assert!(archive_path.join("events.jsonl").exists());
assert!(!forge_dir.join("tasks/T-001.json").exists());
assert!(!forge_dir.join("results/T-001.txt").exists());
}
#[test]
fn test_suggest_version_bump_features() {
let (_tmp, forge_dir) = setup_forge_dir();
create_completed_task(&forge_dir, "T-001", "New feature", "implement");
assert!(matches!(
suggest_version_bump(&forge_dir),
VersionBump::Minor
));
}
#[test]
fn test_suggest_version_bump_fixes_only() {
let (_tmp, forge_dir) = setup_forge_dir();
create_completed_task(&forge_dir, "T-001", "Bug fix", "fix");
create_completed_task(&forge_dir, "T-002", "Another fix", "fix");
assert!(matches!(
suggest_version_bump(&forge_dir),
VersionBump::Patch
));
}
#[test]
fn test_suggest_version_bump_empty() {
let (_tmp, forge_dir) = setup_forge_dir();
assert!(matches!(
suggest_version_bump(&forge_dir),
VersionBump::None
));
}
#[test]
fn test_clean_state() {
let (_tmp, forge_dir) = setup_forge_dir();
std::fs::write(forge_dir.join("tasks/T-001.json"), "{}").unwrap();
let state_mgr = crate::core::state::StateManager::new(&forge_dir);
let state = crate::core::state::ForgeState {
project_name: "test".into(),
current_phase: Some("complete".into()),
..Default::default()
};
state_mgr.save(&state).unwrap();
clean_state(&forge_dir).unwrap();
let cleaned = state_mgr.load().unwrap();
assert!(cleaned.current_phase.is_none());
assert!(!forge_dir.join("tasks/T-001.json").exists());
}
}