use std::path::Path;
use crate::manifest::{Line, MANIFEST};
pub const PACK_FILE: &str = "amont.pack";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Source {
pub label: String,
pub url: String,
pub rev: Option<String>,
}
fn looks_like_path(body: &str) -> bool {
let b = body.as_bytes();
let drive = b.len() >= 3
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& (b[2] == b'\\' || b[2] == b'/');
body.starts_with('/') || drive
|| body.starts_with(r"\\") || std::path::Path::new(body).exists() }
pub fn parse_source(spec: &str) -> Result<Source, String> {
if spec.is_empty() {
return Err("empty source".into());
}
let slash = spec.rfind(['/', '\\']).map(|i| i as isize).unwrap_or(-1);
let (body, rev) = match spec.rfind('@') {
Some(at) if (at as isize) > slash => (&spec[..at], Some(spec[at + 1..].to_string())),
_ => (spec, None),
};
if rev.as_deref().is_some_and(str::is_empty) {
return Err(format!("{spec}: a revision was named but is empty"));
}
let url = if let Some(rest) = body.strip_prefix("github:") {
if rest.split('/').count() != 2 || rest.split('/').any(str::is_empty) {
return Err(format!("{spec}: github: wants owner/repo"));
}
format!("https://github.com/{rest}.git")
} else if let Some(rest) = body.strip_prefix("forgejo:") {
if rest.split('/').count() != 3 || rest.split('/').any(str::is_empty) {
return Err(format!("{spec}: forgejo: wants host/owner/repo"));
}
format!("https://{rest}.git")
} else if body.contains("://") || body.contains('@') || looks_like_path(body) {
body.to_string()
} else {
return Err(format!(
"{spec}: not a git URL — use github:owner/repo, \
forgejo:host/owner/repo, or a full URL"
));
};
Ok(Source {
label: body.to_string(),
url,
rev,
})
}
pub fn resolve(source: &Source) -> Result<String, String> {
let rev = source.rev.as_deref().unwrap_or("HEAD");
let out = crate::git::stdout(&["ls-remote", &source.url, rev]).ok_or_else(|| {
format!(
"{}: cannot reach the remote, or {rev} names nothing",
source.label
)
})?;
let refs = named_refs(&out);
let mut ids: Vec<&str> = refs.iter().map(|(id, _)| id.as_str()).collect();
ids.sort_unstable();
ids.dedup();
match ids.as_slice() {
[] => Err(format!(
"{}: {rev} names nothing on that remote",
source.label
)),
[one] => Ok((*one).to_string()),
_ => {
let listed = refs
.iter()
.map(|(id, name)| format!("{name} @ {}", &id[..7.min(id.len())]))
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"{}: {rev} is ambiguous — it names {} different commits on \
that remote ({listed}); name a commit id instead",
source.label,
ids.len()
))
}
}
}
fn named_refs(out: &str) -> Vec<(String, String)> {
out.lines()
.filter_map(|l| {
let mut it = l.split_whitespace();
let id = it.next()?;
let name = it.next().unwrap_or("");
(!name.ends_with("^{}")).then(|| (id.to_string(), name.to_string()))
})
.collect()
}
pub fn fetch(source: &Source, id: &str, into: &Path) -> Result<String, String> {
let dir = into.to_string_lossy().into_owned();
let ok = |args: &[&str]| crate::git::succeeds_in(into, args);
std::fs::create_dir_all(into).map_err(|e| format!("cannot create {dir}: {e}"))?;
if !ok(&["init", "-q"]) {
return Err(format!("cannot init a scratch repository at {dir}"));
}
let rev = source.rev.as_deref().unwrap_or("HEAD");
if !ok(&["fetch", "-q", "--depth", "1", &source.url, rev]) {
return Err(format!("{}: fetching {rev} failed", source.label));
}
let got = crate::git::stdout_in(into, &["rev-parse", "FETCH_HEAD"])
.ok_or_else(|| format!("{}: nothing was fetched", source.label))?;
if got != id {
return Err(format!(
"{}: {rev} moved while we were reading it ({id} → {got}) — \
nothing was written; run the same command again",
source.label
));
}
crate::git::stdout_in(into, &["show", &format!("FETCH_HEAD:{PACK_FILE}")]).ok_or_else(|| {
format!(
"{}: has no {PACK_FILE} at {}",
source.label,
&id[..7.min(id.len())]
)
})
}
pub fn rows(text: &str) -> Result<Vec<String>, String> {
let source_rows: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
let parsed = crate::manifest::parse_lines(text);
if parsed.len() != source_rows.len() {
return Err(format!("{PACK_FILE}: cannot be read as {MANIFEST} syntax"));
}
if source_rows.is_empty() {
return Err(format!("{PACK_FILE}: declares no checks"));
}
let mut out = Vec::with_capacity(source_rows.len());
for (line, parsed) in source_rows.iter().zip(&parsed) {
match parsed {
Line::Usable(_) => out.push((*line).to_string()),
Line::Broken { why, .. } => {
return Err(format!("{PACK_FILE}: `{line}` — {why}"));
}
Line::Tool(_) | Line::Policy { .. } | Line::Tree(_) => {
return Err(format!(
"{PACK_FILE}: `{line}` — a pack may declare checks only, \
not tool pins, policy or tree gates"
));
}
}
}
Ok(out)
}
fn start_marker(label: &str) -> String {
format!("# amont:pack:start {label}")
}
fn end_marker(label: &str) -> String {
format!("# amont:pack:end {label}")
}
pub fn block(label: &str, id: &str, rows: &[String]) -> String {
let mut out = format!("{} {id}\n", start_marker(label));
for r in rows {
out.push_str(r);
out.push('\n');
}
out.push_str(&end_marker(label));
out.push('\n');
out
}
pub fn splice(manifest: &str, label: &str, id: &str, rows: &[String]) -> Result<String, String> {
let (start, end) = (start_marker(label), end_marker(label));
let at_start = manifest.find(&start);
let at_end = manifest.find(&end);
let fresh = block(label, id, rows);
match (at_start, at_end) {
(Some(s), Some(e)) if e > s => {
let mut tail = e + end.len();
if manifest[tail..].starts_with('\n') {
tail += 1;
}
Ok(format!("{}{fresh}{}", &manifest[..s], &manifest[tail..]))
}
(None, None) => {
let mut out = manifest.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(&fresh);
Ok(out)
}
_ => Err(format!(
"{MANIFEST}: has an unpaired `amont:pack` marker for {label} — \
fix or remove it by hand"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_peeled_tag_line_is_not_a_second_ref() {
let out = "fdfa39b\trefs/tags/v1\n2127b95\trefs/tags/v1^{}\n";
let refs = named_refs(out);
assert_eq!(refs.len(), 1, "{refs:?}");
assert_eq!(
refs[0].0, "fdfa39b",
"the TAG object, which is what FETCH_HEAD records"
);
}
#[test]
fn shorthands_and_urls_become_git_urls() {
let s = parse_source("github:acme/rust-strict").unwrap();
assert_eq!(s.url, "https://github.com/acme/rust-strict.git");
assert_eq!(s.label, "github:acme/rust-strict");
assert_eq!(s.rev, None);
let s = parse_source("forgejo:git.example.org/acme/packs@v2").unwrap();
assert_eq!(s.url, "https://git.example.org/acme/packs.git");
assert_eq!(s.rev.as_deref(), Some("v2"));
}
#[test]
fn an_ssh_url_keeps_its_userinfo() {
let s = parse_source("git@github.com:acme/repo.git").unwrap();
assert_eq!(s.url, "git@github.com:acme/repo.git");
assert_eq!(s.rev, None);
let s = parse_source("git@github.com:acme/repo.git@v1").unwrap();
assert_eq!(s.url, "git@github.com:acme/repo.git");
assert_eq!(s.rev.as_deref(), Some("v1"));
}
#[test]
fn an_absolute_path_is_a_source_on_any_platform() {
for p in ["/tmp/pack", r"C:\Users\me\pack", r"\\server\share\pack"] {
let s = parse_source(p).unwrap_or_else(|e| panic!("{p:?} should be a source: {e}"));
assert_eq!(s.url, p);
assert_eq!(s.rev, None, "{p:?} has no revision");
}
}
#[test]
fn a_windows_path_with_an_at_sign_is_not_split_on_it() {
let s = parse_source(r"C:\Users\me\a@b\pack").unwrap();
assert_eq!(s.url, r"C:\Users\me\a@b\pack");
assert_eq!(s.rev, None);
let s = parse_source(r"C:\Users\me\a@b\pack@v1").unwrap();
assert_eq!(s.url, r"C:\Users\me\a@b\pack");
assert_eq!(s.rev.as_deref(), Some("v1"));
}
#[test]
fn a_source_that_is_not_a_url_is_refused() {
for bad in ["", "acme/rust-strict", "github:acme", "github:acme/repo/x"] {
assert!(parse_source(bad).is_err(), "{bad:?} should be refused");
}
assert!(parse_source("github:acme/repo@").is_err(), "empty revision");
}
#[test]
fn rows_are_taken_verbatim() {
let text = "# a comment\n\npre-commit terraform-fmt Dockerfile block terraform-fmt\n";
assert_eq!(
rows(text).unwrap(),
vec!["pre-commit terraform-fmt Dockerfile block terraform-fmt"]
);
}
#[test]
fn a_pack_may_not_carry_valid_policy_or_pins() {
for bad in [
"set largeFileBlock 4000\n",
"severity secrets warn\n",
"skip secrets\n",
"tool terraform-fmt 2.12\n",
] {
let text = format!("pre-commit ok * block true\n{bad}");
let err = rows(&text).unwrap_err();
assert!(err.contains("checks only"), "{bad:?} gave: {err}");
}
}
#[test]
fn a_policy_line_the_parser_rejects_is_still_refused() {
let text = "pre-commit ok * block true\nset amont.fix true\n";
let err = rows(text).unwrap_err();
assert!(err.contains("not a policy-settable key"), "{err}");
}
#[test]
fn a_pack_is_refused_whole_on_one_bad_row() {
let text = "pre-commit fine * block true\nnonsense\n";
assert!(rows(text).is_err());
assert!(rows("# nothing but a comment\n").is_err(), "empty pack");
}
#[test]
fn splice_appends_then_replaces_in_place() {
let rows0 = vec!["pre-commit a * block true".to_string()];
let base = "pre-commit mine * block true\n";
let once = splice(base, "github:acme/p", "abc1234", &rows0).unwrap();
assert!(once.starts_with(base), "existing lines are kept: {once:?}");
assert!(once.contains("# amont:pack:start github:acme/p abc1234"));
let rows1 = vec!["pre-commit b * block true".to_string()];
let twice = splice(&once, "github:acme/p", "def5678", &rows1).unwrap();
assert_eq!(twice.matches("amont:pack:start github:acme/p").count(), 1);
assert!(twice.contains("def5678"));
assert!(!twice.contains("abc1234"));
assert!(!twice.contains("pre-commit a *"), "old rows are gone");
assert!(
twice.contains("pre-commit mine"),
"unrelated lines survive"
);
}
#[test]
fn an_unpaired_marker_is_reported_not_guessed() {
let rows0 = vec!["pre-commit a * block true".to_string()];
let half = "# amont:pack:start github:acme/p abc1234\n";
assert!(splice(half, "github:acme/p", "x", &rows0).is_err());
let inverted = "# amont:pack:end github:acme/p\n# amont:pack:start github:acme/p x\n";
assert!(splice(inverted, "github:acme/p", "y", &rows0).is_err());
}
#[test]
fn packs_from_different_sources_do_not_collide() {
let a = vec!["pre-commit a * block true".to_string()];
let b = vec!["pre-commit b * block true".to_string()];
let one = splice("", "github:x/a", "1111111", &a).unwrap();
let two = splice(&one, "github:x/b", "2222222", &b).unwrap();
let three = splice(&two, "github:x/a", "3333333", &a).unwrap();
assert!(three.contains("2222222"), "the other pack is untouched");
assert!(three.contains("3333333"));
assert!(!three.contains("1111111"));
}
}