shipit 1.4.8

Shipit is an open source command line interface for managing merge requests, changelogs, tags, and releases using a plan and apply interface. Built with coding agent integration in mind.
Documentation
use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::error::ShipItError;

/// A field value paired with metadata describing what produced it.
///
/// `generated_by` is one of:
/// - `"user"` — supplied explicitly via a CLI flag
/// - `"default"` — computed automatically (e.g. from the latest tag)
/// - `"conventional-commits"` — derived by categorising commits with the conventional-commit rules
/// - `"raw"` — a raw bullet list of commit messages
#[derive(Debug, Serialize, Deserialize)]
pub struct FieldWithSource {
    /// The field value (title text, description markdown, tag name, etc.).
    pub value: String,
    /// What generated this value.
    pub generated_by: String,
}

/// A serializable plan for opening a branch-to-branch merge/pull request.
///
/// Written to `.shipit/plans/<hash>.yml` by `b2b plan` and consumed by `b2b apply`.
#[derive(Debug, Serialize, Deserialize)]
pub struct Plan {
    /// Version of shipit that generated this plan (from `CARGO_PKG_VERSION`).
    pub shipit_version: String,
    /// ISO-8601 UTC timestamp of when the plan was generated.
    pub generated_at: String,
    /// Source branch name.
    pub source: String,
    /// Target branch name.
    pub target: String,
    /// Merge/pull request title.
    pub title: FieldWithSource,
    /// Merge/pull request description (may be markdown).
    pub description: FieldWithSource,
    /// Raw commit messages collected between `source` and `target`.
    pub commits: Vec<String>,
}

/// A serializable plan for creating and pushing an annotated git tag.
///
/// Written to `.shipit/plans/<hash>.yml` by `b2t plan` and consumed by `b2t apply`.
#[derive(Debug, Serialize, Deserialize)]
pub struct TagPlan {
    /// Version of shipit that generated this plan (from `CARGO_PKG_VERSION`).
    pub shipit_version: String,
    /// ISO-8601 UTC timestamp of when the plan was generated.
    pub generated_at: String,
    /// Branch the tag will point at.
    pub branch: String,
    /// Tag name (e.g. `"v1.2.3"`).
    pub tag_name: FieldWithSource,
    /// Annotated tag message / release notes (may be markdown).
    pub notes: FieldWithSource,
    /// Raw commit messages collected since the previous tag.
    pub commits: Vec<String>,
}

/// Write a tag plan YAML file to `<dir>/.shipit/plans/<hash>.yml`.
///
/// The filename is a hex hash of the branch, tag name, and generation timestamp.
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)
}

/// Write a plan YAML file to `<dir>/.shipit/plans/<hash>.yml`.
///
/// The filename is a hex hash of the source branch, target branch, title, and
/// generation timestamp, so each dry-run produces a unique file.
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();
        // Strip the comment header line before deserializing
        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);
    }
}