1use std::path::Path;
44
45use crate::manifest::{Line, MANIFEST};
46
47pub const PACK_FILE: &str = "amont.pack";
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Source {
53 pub label: String,
55 pub url: String,
57 pub rev: Option<String>,
59}
60
61fn looks_like_path(body: &str) -> bool {
75 let b = body.as_bytes();
76 let drive = b.len() >= 3
78 && b[0].is_ascii_alphabetic()
79 && b[1] == b':'
80 && (b[2] == b'\\' || b[2] == b'/');
81 body.starts_with('/') || drive
83 || body.starts_with(r"\\") || std::path::Path::new(body).exists() }
86
87pub fn parse_source(spec: &str) -> Result<Source, String> {
97 if spec.is_empty() {
98 return Err("empty source".into());
99 }
100 let slash = spec.rfind(['/', '\\']).map(|i| i as isize).unwrap_or(-1);
101 let (body, rev) = match spec.rfind('@') {
102 Some(at) if (at as isize) > slash => (&spec[..at], Some(spec[at + 1..].to_string())),
103 _ => (spec, None),
104 };
105 if rev.as_deref().is_some_and(str::is_empty) {
106 return Err(format!("{spec}: a revision was named but is empty"));
107 }
108 let url = if let Some(rest) = body.strip_prefix("github:") {
109 if rest.split('/').count() != 2 || rest.split('/').any(str::is_empty) {
110 return Err(format!("{spec}: github: wants owner/repo"));
111 }
112 format!("https://github.com/{rest}.git")
113 } else if let Some(rest) = body.strip_prefix("forgejo:") {
114 if rest.split('/').count() != 3 || rest.split('/').any(str::is_empty) {
117 return Err(format!("{spec}: forgejo: wants host/owner/repo"));
118 }
119 format!("https://{rest}.git")
120 } else if body.contains("://") || body.contains('@') || looks_like_path(body) {
121 body.to_string()
122 } else {
123 return Err(format!(
124 "{spec}: not a git URL — use github:owner/repo, \
125 forgejo:host/owner/repo, or a full URL"
126 ));
127 };
128 Ok(Source {
129 label: body.to_string(),
130 url,
131 rev,
132 })
133}
134
135pub fn resolve(source: &Source) -> Result<String, String> {
137 let rev = source.rev.as_deref().unwrap_or("HEAD");
138 let out = crate::git::stdout(&["ls-remote", &source.url, rev]).ok_or_else(|| {
139 format!(
140 "{}: cannot reach the remote, or {rev} names nothing",
141 source.label
142 )
143 })?;
144 let refs = named_refs(&out);
153 let mut ids: Vec<&str> = refs.iter().map(|(id, _)| id.as_str()).collect();
154 ids.sort_unstable();
155 ids.dedup();
156 match ids.as_slice() {
157 [] => Err(format!(
158 "{}: {rev} names nothing on that remote",
159 source.label
160 )),
161 [one] => Ok((*one).to_string()),
162 _ => {
163 let listed = refs
164 .iter()
165 .map(|(id, name)| format!("{name} @ {}", &id[..7.min(id.len())]))
166 .collect::<Vec<_>>()
167 .join(", ");
168 Err(format!(
169 "{}: {rev} is ambiguous — it names {} different commits on \
170 that remote ({listed}); name a commit id instead",
171 source.label,
172 ids.len()
173 ))
174 }
175 }
176}
177
178fn named_refs(out: &str) -> Vec<(String, String)> {
188 out.lines()
189 .filter_map(|l| {
190 let mut it = l.split_whitespace();
191 let id = it.next()?;
192 let name = it.next().unwrap_or("");
193 (!name.ends_with("^{}")).then(|| (id.to_string(), name.to_string()))
194 })
195 .collect()
196}
197
198pub fn fetch(source: &Source, id: &str, into: &Path) -> Result<String, String> {
206 let dir = into.to_string_lossy().into_owned();
207 let ok = |args: &[&str]| crate::git::succeeds_in(into, args);
208 std::fs::create_dir_all(into).map_err(|e| format!("cannot create {dir}: {e}"))?;
209 if !ok(&["init", "-q"]) {
210 return Err(format!("cannot init a scratch repository at {dir}"));
211 }
212 let rev = source.rev.as_deref().unwrap_or("HEAD");
213 if !ok(&["fetch", "-q", "--depth", "1", &source.url, rev]) {
214 return Err(format!("{}: fetching {rev} failed", source.label));
215 }
216 let got = crate::git::stdout_in(into, &["rev-parse", "FETCH_HEAD"])
217 .ok_or_else(|| format!("{}: nothing was fetched", source.label))?;
218 if got != id {
219 return Err(format!(
220 "{}: {rev} moved while we were reading it ({id} → {got}) — \
221 nothing was written; run the same command again",
222 source.label
223 ));
224 }
225 crate::git::stdout_in(into, &["show", &format!("FETCH_HEAD:{PACK_FILE}")]).ok_or_else(|| {
226 format!(
227 "{}: has no {PACK_FILE} at {}",
228 source.label,
229 &id[..7.min(id.len())]
230 )
231 })
232}
233
234pub fn rows(text: &str) -> Result<Vec<String>, String> {
246 let source_rows: Vec<&str> = text
249 .lines()
250 .map(str::trim)
251 .filter(|l| !l.is_empty() && !l.starts_with('#'))
252 .collect();
253 let parsed = crate::manifest::parse_lines(text);
254 if parsed.len() != source_rows.len() {
255 return Err(format!("{PACK_FILE}: cannot be read as {MANIFEST} syntax"));
256 }
257 if source_rows.is_empty() {
258 return Err(format!("{PACK_FILE}: declares no checks"));
259 }
260 let mut out = Vec::with_capacity(source_rows.len());
261 for (line, parsed) in source_rows.iter().zip(&parsed) {
262 match parsed {
263 Line::Usable(_) => out.push((*line).to_string()),
264 Line::Broken { why, .. } => {
265 return Err(format!("{PACK_FILE}: `{line}` — {why}"));
266 }
267 Line::Tool(_) | Line::Policy { .. } | Line::Tree(_) => {
273 return Err(format!(
274 "{PACK_FILE}: `{line}` — a pack may declare checks only, \
275 not tool pins, policy or tree gates"
276 ));
277 }
278 }
279 }
280 Ok(out)
281}
282
283fn start_marker(label: &str) -> String {
291 format!("# amont:pack:start {label}")
292}
293fn end_marker(label: &str) -> String {
294 format!("# amont:pack:end {label}")
295}
296
297pub fn block(label: &str, id: &str, rows: &[String]) -> String {
298 let mut out = format!("{} {id}\n", start_marker(label));
299 for r in rows {
300 out.push_str(r);
301 out.push('\n');
302 }
303 out.push_str(&end_marker(label));
304 out.push('\n');
305 out
306}
307
308pub fn splice(manifest: &str, label: &str, id: &str, rows: &[String]) -> Result<String, String> {
314 let (start, end) = (start_marker(label), end_marker(label));
315 let at_start = manifest.find(&start);
316 let at_end = manifest.find(&end);
317 let fresh = block(label, id, rows);
318 match (at_start, at_end) {
319 (Some(s), Some(e)) if e > s => {
320 let mut tail = e + end.len();
321 if manifest[tail..].starts_with('\n') {
322 tail += 1;
323 }
324 Ok(format!("{}{fresh}{}", &manifest[..s], &manifest[tail..]))
325 }
326 (None, None) => {
327 let mut out = manifest.to_string();
328 if !out.is_empty() && !out.ends_with('\n') {
329 out.push('\n');
330 }
331 if !out.is_empty() {
332 out.push('\n');
333 }
334 out.push_str(&fresh);
335 Ok(out)
336 }
337 _ => Err(format!(
338 "{MANIFEST}: has an unpaired `amont:pack` marker for {label} — \
339 fix or remove it by hand"
340 )),
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
352 fn a_peeled_tag_line_is_not_a_second_ref() {
353 let out = "fdfa39b\trefs/tags/v1\n2127b95\trefs/tags/v1^{}\n";
354 let refs = named_refs(out);
355 assert_eq!(refs.len(), 1, "{refs:?}");
356 assert_eq!(
357 refs[0].0, "fdfa39b",
358 "the TAG object, which is what FETCH_HEAD records"
359 );
360 }
361
362 #[test]
363 fn shorthands_and_urls_become_git_urls() {
364 let s = parse_source("github:acme/rust-strict").unwrap();
365 assert_eq!(s.url, "https://github.com/acme/rust-strict.git");
366 assert_eq!(s.label, "github:acme/rust-strict");
367 assert_eq!(s.rev, None);
368
369 let s = parse_source("forgejo:git.example.org/acme/packs@v2").unwrap();
370 assert_eq!(s.url, "https://git.example.org/acme/packs.git");
371 assert_eq!(s.rev.as_deref(), Some("v2"));
372 }
373
374 #[test]
378 fn an_ssh_url_keeps_its_userinfo() {
379 let s = parse_source("git@github.com:acme/repo.git").unwrap();
380 assert_eq!(s.url, "git@github.com:acme/repo.git");
381 assert_eq!(s.rev, None);
382
383 let s = parse_source("git@github.com:acme/repo.git@v1").unwrap();
384 assert_eq!(s.url, "git@github.com:acme/repo.git");
385 assert_eq!(s.rev.as_deref(), Some("v1"));
386 }
387
388 #[test]
398 fn an_absolute_path_is_a_source_on_any_platform() {
399 for p in ["/tmp/pack", r"C:\Users\me\pack", r"\\server\share\pack"] {
400 let s = parse_source(p).unwrap_or_else(|e| panic!("{p:?} should be a source: {e}"));
401 assert_eq!(s.url, p);
402 assert_eq!(s.rev, None, "{p:?} has no revision");
403 }
404 }
405
406 #[test]
409 fn a_windows_path_with_an_at_sign_is_not_split_on_it() {
410 let s = parse_source(r"C:\Users\me\a@b\pack").unwrap();
411 assert_eq!(s.url, r"C:\Users\me\a@b\pack");
412 assert_eq!(s.rev, None);
413 let s = parse_source(r"C:\Users\me\a@b\pack@v1").unwrap();
414 assert_eq!(s.url, r"C:\Users\me\a@b\pack");
415 assert_eq!(s.rev.as_deref(), Some("v1"));
416 }
417
418 #[test]
419 fn a_source_that_is_not_a_url_is_refused() {
420 for bad in ["", "acme/rust-strict", "github:acme", "github:acme/repo/x"] {
421 assert!(parse_source(bad).is_err(), "{bad:?} should be refused");
422 }
423 assert!(parse_source("github:acme/repo@").is_err(), "empty revision");
424 }
425
426 #[test]
427 fn rows_are_taken_verbatim() {
428 let text = "# a comment\n\npre-commit terraform-fmt Dockerfile block terraform-fmt\n";
429 assert_eq!(
430 rows(text).unwrap(),
431 vec!["pre-commit terraform-fmt Dockerfile block terraform-fmt"]
432 );
433 }
434
435 #[test]
444 fn a_pack_may_not_carry_valid_policy_or_pins() {
445 for bad in [
446 "set largeFileBlock 4000\n",
447 "severity secrets warn\n",
448 "skip secrets\n",
449 "tool terraform-fmt 2.12\n",
450 ] {
451 let text = format!("pre-commit ok * block true\n{bad}");
452 let err = rows(&text).unwrap_err();
453 assert!(err.contains("checks only"), "{bad:?} gave: {err}");
454 }
455 }
456
457 #[test]
460 fn a_policy_line_the_parser_rejects_is_still_refused() {
461 let text = "pre-commit ok * block true\nset amont.fix true\n";
462 let err = rows(text).unwrap_err();
463 assert!(err.contains("not a policy-settable key"), "{err}");
464 }
465
466 #[test]
467 fn a_pack_is_refused_whole_on_one_bad_row() {
468 let text = "pre-commit fine * block true\nnonsense\n";
469 assert!(rows(text).is_err());
470 assert!(rows("# nothing but a comment\n").is_err(), "empty pack");
471 }
472
473 #[test]
474 fn splice_appends_then_replaces_in_place() {
475 let rows0 = vec!["pre-commit a * block true".to_string()];
476 let base = "pre-commit mine * block true\n";
477
478 let once = splice(base, "github:acme/p", "abc1234", &rows0).unwrap();
479 assert!(once.starts_with(base), "existing lines are kept: {once:?}");
480 assert!(once.contains("# amont:pack:start github:acme/p abc1234"));
481
482 let rows1 = vec!["pre-commit b * block true".to_string()];
485 let twice = splice(&once, "github:acme/p", "def5678", &rows1).unwrap();
486 assert_eq!(twice.matches("amont:pack:start github:acme/p").count(), 1);
487 assert!(twice.contains("def5678"));
488 assert!(!twice.contains("abc1234"));
489 assert!(!twice.contains("pre-commit a *"), "old rows are gone");
490 assert!(
491 twice.contains("pre-commit mine"),
492 "unrelated lines survive"
493 );
494 }
495
496 #[test]
497 fn an_unpaired_marker_is_reported_not_guessed() {
498 let rows0 = vec!["pre-commit a * block true".to_string()];
499 let half = "# amont:pack:start github:acme/p abc1234\n";
500 assert!(splice(half, "github:acme/p", "x", &rows0).is_err());
501 let inverted = "# amont:pack:end github:acme/p\n# amont:pack:start github:acme/p x\n";
502 assert!(splice(inverted, "github:acme/p", "y", &rows0).is_err());
503 }
504
505 #[test]
507 fn packs_from_different_sources_do_not_collide() {
508 let a = vec!["pre-commit a * block true".to_string()];
509 let b = vec!["pre-commit b * block true".to_string()];
510 let one = splice("", "github:x/a", "1111111", &a).unwrap();
511 let two = splice(&one, "github:x/b", "2222222", &b).unwrap();
512 let three = splice(&two, "github:x/a", "3333333", &a).unwrap();
513 assert!(three.contains("2222222"), "the other pack is untouched");
514 assert!(three.contains("3333333"));
515 assert!(!three.contains("1111111"));
516 }
517}