use crate::git::{CommitInfo, short_sha};
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
pub struct ScopeMdParams<'a> {
pub dir: &'a Path,
pub include: &'a [String],
pub exclude: &'a [String],
pub wip: bool,
pub base_ref: &'a str,
pub target_ref: &'a str,
pub scoped_files: &'a [&'a str],
pub total_files: usize,
pub wip_scoped_files: &'a [&'a str],
pub scoped_commits: &'a [&'a CommitInfo],
pub total_commits: usize,
}
pub fn write_scope_md(params: &ScopeMdParams<'_>) -> Result<()> {
let mut content = String::from("# Scoped Review Pack\n\n");
content.push_str(&format!("**Base:** {}\n", params.base_ref));
content.push_str(&format!("**Target:** {}\n", params.target_ref));
if params.wip {
content.push_str("**Mode:** committed + WIP (staged/unstaged)\n");
}
content.push('\n');
if !params.include.is_empty() {
content.push_str("## Include patterns\n\n");
for p in params.include {
content.push_str(&format!("- `{p}`\n"));
}
content.push('\n');
}
if !params.exclude.is_empty() {
content.push_str("## Exclude patterns\n\n");
for p in params.exclude {
content.push_str(&format!("- `{p}`\n"));
}
content.push('\n');
}
content.push_str(&format!(
"## Stats\n\n- Files in scope: **{}** / {}\n- Commits in scope: **{}** / {}\n\n",
params.scoped_files.len(),
params.total_files,
params.scoped_commits.len(),
params.total_commits,
));
content.push_str("## Files\n\n");
for file in params.scoped_files {
content.push_str(&format!("- `{file}`\n"));
}
for file in params.wip_scoped_files {
if !params.scoped_files.contains(file) {
content.push_str(&format!("- `{file}` (WIP)\n"));
}
}
fs::write(params.dir.join("SCOPE.md"), content).context("Failed to write SCOPE.md")?;
Ok(())
}
pub fn write_commits_log(dir: &Path, commits: &[&CommitInfo]) -> Result<()> {
let mut content = String::new();
for commit in commits {
content.push_str(&format!(
"{} {} <{}> {}\n {}\n\n",
short_sha(&commit.id),
commit.date,
commit.author,
commit.email,
commit.message,
));
}
fs::write(dir.join("commits.log"), content).context("Failed to write commits.log")?;
Ok(())
}
pub fn write_full_patch(dir: &Path, patch: &str) -> Result<()> {
fs::write(dir.join("full.patch"), patch).context("Failed to write full.patch")?;
Ok(())
}
pub fn write_per_commit_patches(
dir: &Path,
patches: &[(usize, &CommitInfo, String)],
) -> Result<()> {
let commit_dir = dir.join("per-commit");
fs::create_dir_all(&commit_dir).context("Failed to create per-commit directory")?;
for (idx, commit, patch) in patches {
if patch.is_empty() {
continue;
}
let slug = slugify(&commit.message);
let filename = format!("{:02}_{}_{}.patch", idx, short_sha(&commit.id), slug);
fs::write(commit_dir.join(&filename), patch)
.with_context(|| format!("Failed to write {filename}"))?;
}
Ok(())
}
pub fn write_per_file_patches(dir: &Path, patches: &[(&str, String)]) -> Result<()> {
let file_dir = dir.join("per-file");
fs::create_dir_all(&file_dir).context("Failed to create per-file directory")?;
for (path, patch) in patches {
if patch.is_empty() {
continue;
}
let filename = format!("{}.patch", crate::config::branch_storage_key(path));
fs::write(file_dir.join(&filename), patch)
.with_context(|| format!("Failed to write per-file patch for {path}"))?;
}
Ok(())
}
pub fn write_wip_patch(dir: &Path, patch: &str) -> Result<()> {
if patch.is_empty() {
return Ok(());
}
fs::write(dir.join("wip.patch"), patch).context("Failed to write wip.patch")?;
Ok(())
}
fn slugify(msg: &str) -> String {
let slug: String = msg
.chars()
.take(40)
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect();
slug.trim_matches('-').to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_basic() {
assert_eq!(
slugify("fix: payment CTA button"),
"fix--payment-cta-button"
);
}
#[test]
fn slugify_long_message() {
let msg = "a".repeat(100);
assert_eq!(slugify(&msg).len(), 40);
}
#[test]
fn slugify_special_chars() {
assert_eq!(
slugify("feat(scope): add --include flag"),
"feat-scope---add---include-flag"
);
}
#[test]
fn per_file_filenames_do_not_collide() {
let dir = tempfile::tempdir().unwrap();
let patches: Vec<(&str, String)> = vec![
("a/b.rs", "SLASH_PATCH\n".to_string()),
("a-b.rs", "DASH_PATCH\n".to_string()),
];
write_per_file_patches(dir.path(), &patches).unwrap();
let pf = dir.path().join("per-file");
let count = std::fs::read_dir(&pf)
.unwrap()
.filter_map(|e| e.ok())
.count();
assert_eq!(
count, 2,
"colliding-looking paths must yield distinct files"
);
let slash = std::fs::read_to_string(pf.join(format!(
"{}.patch",
crate::config::branch_storage_key("a/b.rs")
)))
.unwrap();
assert_eq!(slash, "SLASH_PATCH\n");
let dash = std::fs::read_to_string(pf.join(format!(
"{}.patch",
crate::config::branch_storage_key("a-b.rs")
)))
.unwrap();
assert_eq!(dash, "DASH_PATCH\n");
}
}