use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::ShipItError;
#[derive(Debug, Serialize, Deserialize)]
pub struct FieldWithSource {
pub value: String,
pub generated_by: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Plan {
pub shipit_version: String,
pub generated_at: String,
pub source: String,
pub target: String,
pub title: FieldWithSource,
pub description: FieldWithSource,
pub commits: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TagPlan {
pub shipit_version: String,
pub generated_at: String,
pub branch: String,
pub tag_name: FieldWithSource,
pub notes: FieldWithSource,
pub commits: Vec<String>,
}
pub fn write_tag_plan(plan: &TagPlan, dir: &Path) -> Result<PathBuf, ShipItError> {
let plans_dir = dir.join(".shipit").join("plans");
std::fs::create_dir_all(&plans_dir)
.map_err(|e| ShipItError::Error(format!("Failed to create plans directory: {}", e)))?;
let mut hasher = DefaultHasher::new();
plan.branch.hash(&mut hasher);
plan.tag_name.value.hash(&mut hasher);
plan.generated_at.hash(&mut hasher);
let hash = hasher.finish();
let path = plans_dir.join(format!("{:016x}.yml", hash));
let yaml = serde_yaml::to_string(plan)
.map_err(|e| ShipItError::Error(format!("Failed to serialize plan: {}", e)))?;
let content = format!(
"# Shipit Tag Plan - Generated by shipit v{} on {}\n{}",
plan.shipit_version, plan.generated_at, yaml
);
std::fs::write(&path, content)
.map_err(|e| ShipItError::Error(format!("Failed to write plan: {}", e)))?;
Ok(path)
}
pub fn write_plan(plan: &Plan, dir: &Path) -> Result<PathBuf, ShipItError> {
let plans_dir = dir.join(".shipit").join("plans");
std::fs::create_dir_all(&plans_dir)
.map_err(|e| ShipItError::Error(format!("Failed to create plans directory: {}", e)))?;
let mut hasher = DefaultHasher::new();
plan.source.hash(&mut hasher);
plan.target.hash(&mut hasher);
plan.title.value.hash(&mut hasher);
plan.generated_at.hash(&mut hasher);
let hash = hasher.finish();
let path = plans_dir.join(format!("{:016x}.yml", hash));
let yaml = serde_yaml::to_string(plan)
.map_err(|e| ShipItError::Error(format!("Failed to serialize plan: {}", e)))?;
let content = format!(
"# Shipit Plan - Generated by shipit v{} on {}\n{}",
plan.shipit_version, plan.generated_at, yaml
);
std::fs::write(&path, content)
.map_err(|e| ShipItError::Error(format!("Failed to write plan: {}", e)))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_plan() -> Plan {
Plan {
shipit_version: "1.0.0".to_string(),
generated_at: "2024-01-01T00:00:00Z".to_string(),
source: "feature".to_string(),
target: "main".to_string(),
title: FieldWithSource {
value: "Release Candidate v1.1.0".to_string(),
generated_by: "default".to_string(),
},
description: FieldWithSource {
value: "- feat: something".to_string(),
generated_by: "raw".to_string(),
},
commits: vec!["feat: something".to_string()],
}
}
fn sample_tag_plan() -> TagPlan {
TagPlan {
shipit_version: "1.0.0".to_string(),
generated_at: "2024-01-01T00:00:00Z".to_string(),
branch: "main".to_string(),
tag_name: FieldWithSource {
value: "v1.1.0".to_string(),
generated_by: "default".to_string(),
},
notes: FieldWithSource {
value: "- feat: something".to_string(),
generated_by: "raw".to_string(),
},
commits: vec!["feat: something".to_string()],
}
}
#[test]
fn test_write_plan_creates_file() {
let dir = TempDir::new().unwrap();
let plan = sample_plan();
let path = write_plan(&plan, dir.path()).unwrap();
assert!(path.exists(), "plan file should exist at {:?}", path);
assert_eq!(path.extension().and_then(|e| e.to_str()), Some("yml"));
}
#[test]
fn test_write_plan_content_contains_fields() {
let dir = TempDir::new().unwrap();
let plan = sample_plan();
let path = write_plan(&plan, dir.path()).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("Release Candidate v1.1.0"));
assert!(content.contains("feat: something"));
assert!(content.contains("feature"));
assert!(content.contains("main"));
assert!(content.contains("# Shipit Plan"));
}
#[test]
fn test_write_plan_roundtrip() {
let dir = TempDir::new().unwrap();
let plan = sample_plan();
let path = write_plan(&plan, dir.path()).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let yaml_content: String = content.lines().skip(1).collect::<Vec<_>>().join("\n");
let parsed: Plan = serde_yaml::from_str(&yaml_content).unwrap();
assert_eq!(parsed.source, plan.source);
assert_eq!(parsed.target, plan.target);
assert_eq!(parsed.title.value, plan.title.value);
assert_eq!(parsed.description.value, plan.description.value);
assert_eq!(parsed.commits, plan.commits);
}
#[test]
fn test_write_plan_same_inputs_same_filename() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
let plan = sample_plan();
let path1 = write_plan(&plan, dir1.path()).unwrap();
let path2 = write_plan(&plan, dir2.path()).unwrap();
assert_eq!(
path1.file_name().unwrap(),
path2.file_name().unwrap(),
"same plan inputs should produce the same filename"
);
}
#[test]
fn test_write_plan_different_timestamp_different_filename() {
let dir = TempDir::new().unwrap();
let mut plan1 = sample_plan();
let mut plan2 = sample_plan();
plan1.generated_at = "2024-01-01T00:00:00Z".to_string();
plan2.generated_at = "2024-06-01T12:00:00Z".to_string();
let path1 = write_plan(&plan1, dir.path()).unwrap();
let path2 = write_plan(&plan2, dir.path()).unwrap();
assert_ne!(
path1.file_name().unwrap(),
path2.file_name().unwrap(),
"different timestamps should produce different filenames"
);
}
#[test]
fn test_write_tag_plan_creates_file() {
let dir = TempDir::new().unwrap();
let plan = sample_tag_plan();
let path = write_tag_plan(&plan, dir.path()).unwrap();
assert!(path.exists(), "tag plan file should exist at {:?}", path);
assert_eq!(path.extension().and_then(|e| e.to_str()), Some("yml"));
}
#[test]
fn test_write_tag_plan_content_contains_fields() {
let dir = TempDir::new().unwrap();
let plan = sample_tag_plan();
let path = write_tag_plan(&plan, dir.path()).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("v1.1.0"));
assert!(content.contains("feat: something"));
assert!(content.contains("# Shipit Tag Plan"));
}
#[test]
fn test_write_tag_plan_roundtrip() {
let dir = TempDir::new().unwrap();
let plan = sample_tag_plan();
let path = write_tag_plan(&plan, dir.path()).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let yaml_content: String = content.lines().skip(1).collect::<Vec<_>>().join("\n");
let parsed: TagPlan = serde_yaml::from_str(&yaml_content).unwrap();
assert_eq!(parsed.branch, plan.branch);
assert_eq!(parsed.tag_name.value, plan.tag_name.value);
assert_eq!(parsed.notes.value, plan.notes.value);
assert_eq!(parsed.commits, plan.commits);
}
}