use std::path::{Path, PathBuf};
use std::process::Command;
use spec_spine_core::compact::{Compaction, compact, parse_plan};
use spec_spine_types::Error;
use crate::load_repo_config;
use crate::out;
pub const DEFAULT_MAP_PATH: &str = "docs/corpus-map.md";
pub struct CompactArgs {
pub plan_file: PathBuf,
pub plan: bool,
pub force: bool,
pub map_out: PathBuf,
}
pub fn run(repo: &Path, args: &CompactArgs) -> Result<u8, Error> {
let src = std::fs::read_to_string(&args.plan_file).map_err(|e| {
Error::Io(format!(
"compact: cannot read the plan at {}: {e}",
args.plan_file.display()
))
})?;
let plan = parse_plan(&src)?;
if !args.plan && !args.force {
refuse_dirty_tree(repo)?;
}
let cfg = load_repo_config(repo)?;
let outcome = compact(&cfg, repo, &plan)?;
if !outcome.leftover.is_empty() {
out::line(format_args!(
"\ncompact: REFUSED, {} occurrence(s) of a retired path survived the rewrite and no \
clause spared them:",
outcome.leftover.len()
));
for l in &outcome.leftover {
out::line(format_args!(
" {}:{} [{}] {}",
l.rel_path, l.line, l.path, l.text
));
}
if args.plan {
out::line(format_args!(
"compact: declare a form rule that covers them, name the file or section \
historical, or fix the occurrence. The plan is not ready to apply."
));
} else {
out::line(format_args!(
"compact: declare a form rule that covers them, name the file or section \
historical, or fix the occurrence. Nothing was written."
));
}
return Ok(1);
}
report(&outcome, args.plan);
if args.plan {
out::line(format_args!(
"\ncompact: --plan, so nothing was written. {} rewrite(s) in {} file(s) would be applied.",
outcome.rewrite_count(),
outcome.rewrites.len()
));
return Ok(0);
}
apply(repo, &outcome, &args.map_out)?;
out::line(format_args!(
"\ncompact: applied {} rewrite(s) across {} file(s); {} spec(s) removed. \
Regenerate the derived trees (`spec-spine compile` and `index`) and commit them with this change.",
outcome.rewrite_count(),
outcome.rewrites.len(),
outcome.removed_paths.len()
));
Ok(0)
}
fn report(outcome: &Compaction, verbose: bool) {
out::line(format_args!("map ({} id(s)):", outcome.map.len()));
for e in &outcome.map {
let note = if e.removed { " (removed)" } else { "" };
if e.from != e.to || e.removed {
out::line(format_args!(" {} -> {}{note}", e.from, e.to));
}
}
out::line(format_args!("\nrewrites by form:"));
for (form, n) in &outcome.counts {
out::line(format_args!(" {form:<14} {n}"));
}
if !outcome.skipped.is_empty() {
out::line(format_args!(
"\nleft alone ({}), with the clause that spared each:",
outcome.skipped.len()
));
for s in &outcome.skipped {
out::line(format_args!(
" {}:{} [{} {}] {}",
s.rel_path,
s.line,
s.path,
s.clause.as_str(),
s.text
));
}
}
if verbose {
out::line(format_args!("\nrewrites by file:"));
for file in &outcome.rewrites {
out::line(format_args!(" {}", file.rel_path));
for r in &file.rewrites {
out::line(format_args!(
" {}:{} [{}] {} -> {}",
file.rel_path,
r.line,
r.form.as_str(),
r.old,
r.new
));
}
}
}
}
fn apply(repo: &Path, outcome: &Compaction, map_out: &Path) -> Result<(), Error> {
let io = |e: std::io::Error, what: &str| Error::Io(format!("compact: {what}: {e}"));
for rel in &outcome.removed_paths {
std::fs::remove_dir_all(repo.join(rel)).map_err(|e| io(e, &format!("removing {rel}")))?;
}
for f in &outcome.files {
let to = repo.join(&f.rel_path);
if let Some(dir) = to.parent() {
std::fs::create_dir_all(dir).map_err(|e| io(e, &format!("creating {}", f.rel_path)))?;
}
std::fs::write(&to, &f.contents).map_err(|e| io(e, &format!("writing {}", f.rel_path)))?;
if f.rel_path != f.from_rel_path {
let from = repo.join(&f.from_rel_path);
std::fs::remove_file(&from)
.map_err(|e| io(e, &format!("removing {}", f.from_rel_path)))?;
if let Some(dir) = from.parent() {
let _ = std::fs::remove_dir(dir);
}
}
}
let map_path = repo.join(map_out);
if let Some(dir) = map_path.parent() {
std::fs::create_dir_all(dir).map_err(|e| io(e, "creating the map directory"))?;
}
std::fs::write(&map_path, &outcome.map_document)
.map_err(|e| io(e, &format!("writing {}", map_out.display())))?;
Ok(())
}
fn refuse_dirty_tree(repo: &Path) -> Result<(), Error> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["status", "--porcelain"])
.output()
.map_err(|e| {
Error::Io(format!(
"compact: the working tree's state could not be read ({e}); \
run inside a git repository, or pass --force"
))
})?;
if !out.status.success() {
return Err(Error::Io(format!(
"compact: `git status` exited {:?}: {}; pass --force to rewrite anyway",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let dirty = String::from_utf8_lossy(&out.stdout);
if !dirty.trim().is_empty() {
return Err(Error::Config(format!(
"compact: the working tree is dirty, and a rewrite of the whole corpus \
underneath an uncommitted edit is unreviewable. Commit or stash first, \
or pass --force. If a previous run failed part way through, \
`git checkout .` restores the tree it started from.\n{}",
dirty.trim()
)));
}
Ok(())
}