use std::path::{Path, PathBuf};
use std::process::Command;
use spec_spine_core::couple::spec_id_for_spec_md_path;
use spec_spine_core::{
CoupleReport, DiffFile, DiffInput, FileContents, couple, dependency_only_waiver,
is_bypassed_path, load_committed_index, parse_waiver,
};
use spec_spine_types::{Config, Error, LineSpan, Verdict, Violation, verdict::verb};
use crate::load_repo_config;
use crate::out;
pub struct CoupleArgs {
pub base: String,
pub head: String,
pub pr_body: Option<PathBuf>,
pub paths_from: Option<PathBuf>,
pub json: bool,
}
pub fn run(repo: &Path, args: &CoupleArgs) -> Result<u8, Error> {
let cfg = load_repo_config(repo)?;
let diff = build_diff_input(repo, args)?;
let body = read_pr_body(args)?;
let mut waiver = parse_waiver(&cfg, &body);
let mut auto_waived = false;
if waiver.is_none() && cfg.coupling.auto_waive_dependency_only && args.paths_from.is_none() {
waiver = try_dependency_only_waiver(repo, &cfg, args, &diff)?;
auto_waived = waiver.is_some();
}
let report = couple(&cfg, repo, &diff, waiver.as_ref())?;
if args.json {
let value = serde_json::to_value(&report).map_err(|e| Error::Schema(e.to_string()))?;
let code = report.exit_code();
out::verdict(&Verdict::report(verb::COUPLE, code, value))?;
return Ok(code);
}
if report.has_blocking_drift() {
let unclaimed = report
.violations
.iter()
.filter(|v| v.code == "C-002")
.count();
let drift = report.violations.len() - unclaimed;
if unclaimed == 0 {
eprintln!(
"spec-spine couple: {drift} drift violation(s): a changed path lacks an authoring edit to an owning spec.\n"
);
} else {
eprintln!(
"spec-spine couple: {} violation(s): {drift} drift (C-001), {unclaimed} unclaimed (C-002, require_ownership is on).\n",
report.violations.len()
);
}
for v in &report.violations {
eprintln!(" {} {}", v.code, v.message);
}
eprint!("{}", resolution_footer(&cfg, &report, &diff));
} else if let Some(reason) = &report.waiver {
outln!(
"spec-spine couple: {} violation(s) {}, reason: {reason}",
report.violations.len(),
if auto_waived { "auto-waived" } else { "waived" }
);
for v in &report.violations {
outln!(" {} (waived)", v.message);
}
} else {
outln!(
"spec-spine couple: OK: {} path(s) checked, no drift.",
report.checked_paths
);
}
Ok(report.exit_code())
}
fn resolution_footer(cfg: &Config, report: &CoupleReport, diff: &DiffInput) -> String {
let drift: Vec<&Violation> = report
.violations
.iter()
.filter(|v| v.code == "C-001")
.collect();
let unclaimed = report.violations.len() - drift.len();
let keyword = &cfg.coupling.waiver_keyword;
if drift.is_empty() {
return format!(
"\nResolve by editing an owning spec's spec.md (C-001), claiming the path in a spec's owning edge (C-002), or add a '{keyword}' line to the PR body.\n"
);
}
let mut f = String::from("\nResolve, in the order an author should consider them");
if unclaimed > 0 {
f.push_str(" (1-3 answer C-001; 4 answers C-002)");
}
f.push_str(":\n\n");
f.push_str(
" 1. Edit the owning spec's spec.md. The right door when the spec that owns\n\
\x20 the path is the one you are authoring.\n\n",
);
f.push_str(
" 2. Declare an `extends` edge in your OWN spec, naming the owning spec\n\
\x20 and the unit you touched. That makes your spec a legitimate owner of\n\
\x20 the unit, so the gate clears on the next run. It amends nobody and\n\
\x20 needs no waiver.\n\n",
);
f.push_str(&extends_guidance(cfg, &drift, diff));
f.push_str(&format!(
" 3. Add a '{keyword} <reason>' line to the PR body. A waiver is a\n\
\x20 human instrument: it needs explicit human approval, and is not a\n\
\x20 flag an unattended session sets for itself.\n"
));
if unclaimed > 0 {
f.push_str(
"\n 4. For the unclaimed path(s) above, claim the path in a spec's owning\n\
\x20 edge.\n",
);
}
f
}
fn extends_guidance(cfg: &Config, drift: &[&Violation], diff: &DiffInput) -> String {
let specs_dir = cfg.layout.specs_dir.as_str();
let edited: Vec<&str> = diff
.files
.iter()
.filter_map(|f| spec_id_for_spec_md_path(specs_dir, &f.path))
.collect();
let Some(id) = edited.first().filter(|_| edited.len() == 1) else {
return concat!(
" In your spec's frontmatter:\n\n",
" extends:\n",
" - { spec: \"<owning-spec-id>\", unit: \"<path>\", nature: additive }\n\n",
)
.to_string();
};
let mut s = format!(
" spec {id} is the only spec.md edited in this diff, and owns none of\n\
\x20 the paths above. Cross into the owning territory by declaring, in\n\
\x20 {}:\n\n\
\x20 extends:\n",
spec_md_rel(specs_dir, id)
);
for v in drift {
let (Some(path), Some(owner)) = (v.path.as_deref(), v.owners.first()) else {
continue;
};
s.push_str(&format!(
" - {{ spec: \"{owner}\", unit: \"{path}\", nature: additive }}\n"
));
}
s.push_str(
"\n A suggestion, not an instruction, and not the only correct form:\n\
\x20 where a path has several owners any one of them clears it, a narrower\n\
\x20 section or symbol unit is available if you want the claim tighter,\n\
\x20 and `nature` is a free-text hint (`superseding` describes a crossing\n\
\x20 that replaces behavior rather than adding to it). If you did not mean\n\
\x20 to touch the path, revert the touch and declare nothing.\n\n",
);
s
}
fn spec_md_rel(specs_dir: &str, id: &str) -> String {
format!("{}/{id}/spec.md", specs_dir.trim_end_matches('/'))
}
fn build_diff_input(repo: &Path, args: &CoupleArgs) -> Result<DiffInput, Error> {
if let Some(path) = &args.paths_from {
let text = std::fs::read_to_string(path)
.map_err(|e| Error::Io(format!("read --paths-from {}: {e}", path.display())))?;
let files = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|p| DiffFile {
path: p.to_string(),
hunks: Vec::new(),
deleted: false,
})
.collect();
return Ok(DiffInput { files });
}
let raw = run_git_diff(repo, &args.base, &args.head)?;
Ok(parse_unified_diff(&raw))
}
fn try_dependency_only_waiver(
repo: &Path,
cfg: &Config,
args: &CoupleArgs,
diff: &DiffInput,
) -> Result<Option<spec_spine_core::Waiver>, Error> {
let Ok(index) = load_committed_index(cfg, repo) else {
return Ok(None);
};
let candidates: Vec<&DiffFile> = diff
.files
.iter()
.filter(|f| !is_bypassed_path(cfg, &index, &f.path))
.collect();
if candidates.is_empty()
|| !candidates
.iter()
.all(|f| spec_spine_core::is_dependency_manifest(&f.path))
{
return Ok(None);
}
let Some(merge_base) = git_merge_base(repo, &args.base, &args.head) else {
return Ok(None);
};
let mut files: Vec<FileContents> = Vec::with_capacity(candidates.len());
for f in &candidates {
files.push(FileContents {
path: f.path.clone(),
base: git_show(repo, &merge_base, &f.path),
head: git_show(repo, &args.head, &f.path),
});
}
Ok(dependency_only_waiver(&files))
}
fn git_merge_base(repo: &Path, base: &str, head: &str) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["merge-base", "--end-of-options", base, head])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let rev = String::from_utf8_lossy(&out.stdout).trim().to_string();
if rev.is_empty() { None } else { Some(rev) }
}
fn git_show(repo: &Path, rev: &str, path: &str) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["show", "--end-of-options", &format!("{rev}:{path}")])
.output()
.ok()?;
if !out.status.success() {
return None; }
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn run_git_diff(repo: &Path, base: &str, head: &str) -> Result<String, Error> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["-c", "core.quotepath=false"])
.args([
"diff",
"--no-color",
"-U0",
"--no-renames",
"--end-of-options",
])
.arg(format!("{base}...{head}"))
.output()
.map_err(|e| Error::Io(format!("spawn git diff: {e}")))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(Error::Io(format!(
"git diff exited {:?}: {stderr}",
out.status.code()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn parse_unified_diff(diff_text: &str) -> DiffInput {
use std::collections::BTreeMap;
#[derive(Default)]
struct Entry {
hunks: Vec<LineSpan>,
deleted: bool,
}
let mut files: BTreeMap<String, Entry> = BTreeMap::new();
let mut current_path: Option<String> = None;
let mut minus_path: Option<String> = None;
for line in diff_text.lines() {
if let Some(rest) = line.strip_prefix("--- ") {
minus_path = strip_diff_prefix(rest.trim());
} else if let Some(rest) = line.strip_prefix("+++ ") {
let p = rest.trim();
let deleted = p == "/dev/null";
if deleted {
current_path = minus_path.clone();
} else {
current_path = strip_diff_prefix(p);
}
if let Some(path) = ¤t_path {
let entry = files.entry(path.clone()).or_default();
entry.deleted = deleted;
}
} else if line.starts_with("@@") {
if let Some(path) = ¤t_path {
if let Some(span) = parse_hunk_header(line) {
files.entry(path.clone()).or_default().hunks.push(span);
}
}
}
}
DiffInput {
files: files
.into_iter()
.map(|(path, entry)| DiffFile {
path,
hunks: entry.hunks,
deleted: entry.deleted,
})
.collect(),
}
}
fn strip_diff_prefix(p: &str) -> Option<String> {
if p == "/dev/null" {
return None;
}
Some(
p.strip_prefix("a/")
.or_else(|| p.strip_prefix("b/"))
.unwrap_or(p)
.to_string(),
)
}
fn parse_hunk_header(line: &str) -> Option<LineSpan> {
let after_at = line.strip_prefix("@@")?.trim_start();
let rest = after_at.strip_prefix('-')?;
let plus_pos = rest.find('+')?;
let new_part = rest[plus_pos + 1..].trim_start();
let new_range = new_part.split_whitespace().next()?;
let (start_s, count_s) = match new_range.split_once(',') {
Some((a, b)) => (a, b),
None => (new_range, "1"),
};
let start: usize = start_s.parse().ok()?;
let count: usize = count_s.parse().ok()?;
if start == 0 {
return None;
}
let count = count.max(1);
Some(LineSpan::new(start, start + count - 1))
}
fn read_pr_body(args: &CoupleArgs) -> Result<String, Error> {
if let Some(path) = &args.pr_body {
std::fs::read_to_string(path)
.map_err(|e| Error::Io(format!("read --pr-body {}: {e}", path.display())))
} else if let Ok(s) = std::env::var("SPEC_SPINE_PR_BODY") {
Ok(s)
} else {
Ok(String::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_modification_hunks_to_inclusive_spans() {
let diff = "diff --git a/Makefile b/Makefile\n\
--- a/Makefile\n\
+++ b/Makefile\n\
@@ -10,2 +10,3 @@ ctx\n\
@@ -50 +51,5 @@\n";
let d = parse_unified_diff(diff);
let f = d.files.iter().find(|f| f.path == "Makefile").unwrap();
assert_eq!(f.hunks, vec![LineSpan::new(10, 12), LineSpan::new(51, 55)]);
}
#[test]
fn deleted_file_is_whole_file_change() {
let diff = "diff --git a/gone.rs b/gone.rs\n\
deleted file mode 100644\n\
--- a/gone.rs\n\
+++ /dev/null\n\
@@ -1,5 +0,0 @@\n";
let d = parse_unified_diff(diff);
let f = d.files.iter().find(|f| f.path == "gone.rs").unwrap();
assert!(f.hunks.is_empty(), "deletion ⇒ whole-file (no hunks)");
assert!(f.deleted, "deletion is flagged for the ownership ratchet");
}
#[test]
fn modified_and_added_files_are_not_deleted() {
let diff = "diff --git a/Makefile b/Makefile\n\
--- a/Makefile\n\
+++ b/Makefile\n\
@@ -10,2 +10,3 @@ ctx\n\
diff --git a/new.rs b/new.rs\n\
new file mode 100644\n\
--- /dev/null\n\
+++ b/new.rs\n\
@@ -0,0 +1,3 @@\n";
let d = parse_unified_diff(diff);
assert!(d.files.iter().all(|f| !f.deleted), "{:?}", d.files);
}
#[test]
fn rename_under_no_renames_registers_both_paths() {
let diff = "diff --git a/old/mod.rs b/old/mod.rs\n\
deleted file mode 100644\n\
--- a/old/mod.rs\n\
+++ /dev/null\n\
@@ -1,3 +0,0 @@\n\
diff --git a/new/mod.rs b/new/mod.rs\n\
new file mode 100644\n\
--- /dev/null\n\
+++ b/new/mod.rs\n\
@@ -0,0 +1,3 @@\n";
let d = parse_unified_diff(diff);
assert!(
d.files.iter().any(|f| f.path == "old/mod.rs"),
"vacated path must be seen"
);
assert!(
d.files.iter().any(|f| f.path == "new/mod.rs"),
"new path must be seen"
);
}
}