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 ids: Vec<&str> = out
149 .lines()
150 .filter_map(|l| l.split_whitespace().next())
151 .collect();
152 match ids.as_slice() {
153 [] => Err(format!(
154 "{}: {rev} names nothing on that remote",
155 source.label
156 )),
157 [one] => Ok((*one).to_string()),
158 many => Err(format!(
159 "{}: {rev} is ambiguous — it matches {} refs on that remote; \
160 name a commit id instead",
161 source.label,
162 many.len()
163 )),
164 }
165}
166
167pub fn fetch(source: &Source, id: &str, into: &Path) -> Result<String, String> {
175 let dir = into.to_string_lossy().into_owned();
176 let ok = |args: &[&str]| crate::git::succeeds_in(into, args);
177 std::fs::create_dir_all(into).map_err(|e| format!("cannot create {dir}: {e}"))?;
178 if !ok(&["init", "-q"]) {
179 return Err(format!("cannot init a scratch repository at {dir}"));
180 }
181 let rev = source.rev.as_deref().unwrap_or("HEAD");
182 if !ok(&["fetch", "-q", "--depth", "1", &source.url, rev]) {
183 return Err(format!("{}: fetching {rev} failed", source.label));
184 }
185 let got = crate::git::stdout_in(into, &["rev-parse", "FETCH_HEAD"])
186 .ok_or_else(|| format!("{}: nothing was fetched", source.label))?;
187 if got != id {
188 return Err(format!(
189 "{}: {rev} moved while we were reading it ({id} → {got}) — \
190 nothing was written; run the same command again",
191 source.label
192 ));
193 }
194 crate::git::stdout_in(into, &["show", &format!("FETCH_HEAD:{PACK_FILE}")]).ok_or_else(|| {
195 format!(
196 "{}: has no {PACK_FILE} at {}",
197 source.label,
198 &id[..7.min(id.len())]
199 )
200 })
201}
202
203pub fn rows(text: &str) -> Result<Vec<String>, String> {
215 let source_rows: Vec<&str> = text
218 .lines()
219 .map(str::trim)
220 .filter(|l| !l.is_empty() && !l.starts_with('#'))
221 .collect();
222 let parsed = crate::manifest::parse_lines(text);
223 if parsed.len() != source_rows.len() {
224 return Err(format!("{PACK_FILE}: cannot be read as {MANIFEST} syntax"));
225 }
226 if source_rows.is_empty() {
227 return Err(format!("{PACK_FILE}: declares no checks"));
228 }
229 let mut out = Vec::with_capacity(source_rows.len());
230 for (line, parsed) in source_rows.iter().zip(&parsed) {
231 match parsed {
232 Line::Usable(_) => out.push((*line).to_string()),
233 Line::Broken { why, .. } => {
234 return Err(format!("{PACK_FILE}: `{line}` — {why}"));
235 }
236 Line::Tool(_) | Line::Policy { .. } => {
242 return Err(format!(
243 "{PACK_FILE}: `{line}` — a pack may declare checks only, \
244 not tool pins or policy"
245 ));
246 }
247 }
248 }
249 Ok(out)
250}
251
252fn start_marker(label: &str) -> String {
260 format!("# amont:pack:start {label}")
261}
262fn end_marker(label: &str) -> String {
263 format!("# amont:pack:end {label}")
264}
265
266pub fn block(label: &str, id: &str, rows: &[String]) -> String {
267 let mut out = format!("{} {id}\n", start_marker(label));
268 for r in rows {
269 out.push_str(r);
270 out.push('\n');
271 }
272 out.push_str(&end_marker(label));
273 out.push('\n');
274 out
275}
276
277pub fn splice(manifest: &str, label: &str, id: &str, rows: &[String]) -> Result<String, String> {
283 let (start, end) = (start_marker(label), end_marker(label));
284 let at_start = manifest.find(&start);
285 let at_end = manifest.find(&end);
286 let fresh = block(label, id, rows);
287 match (at_start, at_end) {
288 (Some(s), Some(e)) if e > s => {
289 let mut tail = e + end.len();
290 if manifest[tail..].starts_with('\n') {
291 tail += 1;
292 }
293 Ok(format!("{}{fresh}{}", &manifest[..s], &manifest[tail..]))
294 }
295 (None, None) => {
296 let mut out = manifest.to_string();
297 if !out.is_empty() && !out.ends_with('\n') {
298 out.push('\n');
299 }
300 if !out.is_empty() {
301 out.push('\n');
302 }
303 out.push_str(&fresh);
304 Ok(out)
305 }
306 _ => Err(format!(
307 "{MANIFEST}: has an unpaired `amont:pack` marker for {label} — \
308 fix or remove it by hand"
309 )),
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn shorthands_and_urls_become_git_urls() {
319 let s = parse_source("github:acme/rust-strict").unwrap();
320 assert_eq!(s.url, "https://github.com/acme/rust-strict.git");
321 assert_eq!(s.label, "github:acme/rust-strict");
322 assert_eq!(s.rev, None);
323
324 let s = parse_source("forgejo:git.example.org/acme/packs@v2").unwrap();
325 assert_eq!(s.url, "https://git.example.org/acme/packs.git");
326 assert_eq!(s.rev.as_deref(), Some("v2"));
327 }
328
329 #[test]
333 fn an_ssh_url_keeps_its_userinfo() {
334 let s = parse_source("git@github.com:acme/repo.git").unwrap();
335 assert_eq!(s.url, "git@github.com:acme/repo.git");
336 assert_eq!(s.rev, None);
337
338 let s = parse_source("git@github.com:acme/repo.git@v1").unwrap();
339 assert_eq!(s.url, "git@github.com:acme/repo.git");
340 assert_eq!(s.rev.as_deref(), Some("v1"));
341 }
342
343 #[test]
353 fn an_absolute_path_is_a_source_on_any_platform() {
354 for p in ["/tmp/pack", r"C:\Users\me\pack", r"\\server\share\pack"] {
355 let s = parse_source(p).unwrap_or_else(|e| panic!("{p:?} should be a source: {e}"));
356 assert_eq!(s.url, p);
357 assert_eq!(s.rev, None, "{p:?} has no revision");
358 }
359 }
360
361 #[test]
364 fn a_windows_path_with_an_at_sign_is_not_split_on_it() {
365 let s = parse_source(r"C:\Users\me\a@b\pack").unwrap();
366 assert_eq!(s.url, r"C:\Users\me\a@b\pack");
367 assert_eq!(s.rev, None);
368 let s = parse_source(r"C:\Users\me\a@b\pack@v1").unwrap();
369 assert_eq!(s.url, r"C:\Users\me\a@b\pack");
370 assert_eq!(s.rev.as_deref(), Some("v1"));
371 }
372
373 #[test]
374 fn a_source_that_is_not_a_url_is_refused() {
375 for bad in ["", "acme/rust-strict", "github:acme", "github:acme/repo/x"] {
376 assert!(parse_source(bad).is_err(), "{bad:?} should be refused");
377 }
378 assert!(parse_source("github:acme/repo@").is_err(), "empty revision");
379 }
380
381 #[test]
382 fn rows_are_taken_verbatim() {
383 let text = "# a comment\n\npre-commit terraform-fmt Dockerfile block terraform-fmt\n";
384 assert_eq!(
385 rows(text).unwrap(),
386 vec!["pre-commit terraform-fmt Dockerfile block terraform-fmt"]
387 );
388 }
389
390 #[test]
399 fn a_pack_may_not_carry_valid_policy_or_pins() {
400 for bad in [
401 "set largeFileBlock 4000\n",
402 "severity secrets warn\n",
403 "skip secrets\n",
404 "tool terraform-fmt 2.12\n",
405 ] {
406 let text = format!("pre-commit ok * block true\n{bad}");
407 let err = rows(&text).unwrap_err();
408 assert!(err.contains("checks only"), "{bad:?} gave: {err}");
409 }
410 }
411
412 #[test]
415 fn a_policy_line_the_parser_rejects_is_still_refused() {
416 let text = "pre-commit ok * block true\nset amont.fix true\n";
417 let err = rows(text).unwrap_err();
418 assert!(err.contains("not a policy-settable key"), "{err}");
419 }
420
421 #[test]
422 fn a_pack_is_refused_whole_on_one_bad_row() {
423 let text = "pre-commit fine * block true\nnonsense\n";
424 assert!(rows(text).is_err());
425 assert!(rows("# nothing but a comment\n").is_err(), "empty pack");
426 }
427
428 #[test]
429 fn splice_appends_then_replaces_in_place() {
430 let rows0 = vec!["pre-commit a * block true".to_string()];
431 let base = "pre-commit mine * block true\n";
432
433 let once = splice(base, "github:acme/p", "abc1234", &rows0).unwrap();
434 assert!(once.starts_with(base), "existing lines are kept: {once:?}");
435 assert!(once.contains("# amont:pack:start github:acme/p abc1234"));
436
437 let rows1 = vec!["pre-commit b * block true".to_string()];
440 let twice = splice(&once, "github:acme/p", "def5678", &rows1).unwrap();
441 assert_eq!(twice.matches("amont:pack:start github:acme/p").count(), 1);
442 assert!(twice.contains("def5678"));
443 assert!(!twice.contains("abc1234"));
444 assert!(!twice.contains("pre-commit a *"), "old rows are gone");
445 assert!(
446 twice.contains("pre-commit mine"),
447 "unrelated lines survive"
448 );
449 }
450
451 #[test]
452 fn an_unpaired_marker_is_reported_not_guessed() {
453 let rows0 = vec!["pre-commit a * block true".to_string()];
454 let half = "# amont:pack:start github:acme/p abc1234\n";
455 assert!(splice(half, "github:acme/p", "x", &rows0).is_err());
456 let inverted = "# amont:pack:end github:acme/p\n# amont:pack:start github:acme/p x\n";
457 assert!(splice(inverted, "github:acme/p", "y", &rows0).is_err());
458 }
459
460 #[test]
462 fn packs_from_different_sources_do_not_collide() {
463 let a = vec!["pre-commit a * block true".to_string()];
464 let b = vec!["pre-commit b * block true".to_string()];
465 let one = splice("", "github:x/a", "1111111", &a).unwrap();
466 let two = splice(&one, "github:x/b", "2222222", &b).unwrap();
467 let three = splice(&two, "github:x/a", "3333333", &a).unwrap();
468 assert!(three.contains("2222222"), "the other pack is untouched");
469 assert!(three.contains("3333333"));
470 assert!(!three.contains("1111111"));
471 }
472}