use crate::manifest::{self, Event, PackId, SCHEMA_VERSION};
use chrono::Utc;
use std::path::Path;
use thiserror::Error;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddRequest {
pub url: String,
pub path: String,
pub pack_type: String,
pub git_ref: Option<crate::refspec::Ref>,
}
impl AddRequest {
pub fn new(
url: impl Into<String>,
path: impl Into<String>,
pack_type: impl Into<String>,
) -> Self {
Self { url: url.into(), path: path.into(), pack_type: pack_type.into(), git_ref: None }
}
pub fn with_ref(mut self, r: crate::refspec::Ref) -> Self {
self.git_ref = Some(r);
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AddOpts {
pub dry_run: bool,
}
impl AddOpts {
pub fn new(dry_run: bool) -> Self {
Self { dry_run }
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddReport {
pub id: PackId,
pub url: String,
pub path: String,
pub pack_type: String,
pub dry_run: bool,
pub appended: bool,
pub refdir: Option<String>,
pub ref_action: Option<crate::refspec::RefAction>,
}
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum AddError {
#[error("manifest write failed: {0}")]
Manifest(#[from] manifest::ManifestError),
}
fn probe_manifest_for_ref(
manifest_path: &Path,
candidate_url: &str,
candidate_refdir: &str,
) -> crate::refspec::AddContext {
let events = match manifest::read_all(manifest_path) {
Ok(ev) => ev,
Err(_) => return crate::refspec::AddContext::default(),
};
let mut url_tracked = false;
let mut dup_hit = false;
for ev in &events {
if let Event::Add { url, path, .. } = ev {
if url == candidate_url {
url_tracked = true;
if path == candidate_refdir {
dup_hit = true;
break;
}
}
}
}
crate::refspec::AddContext { url_tracked, dup_hit }
}
pub fn add_pack(
manifest_path: &Path,
request: AddRequest,
opts: AddOpts,
) -> Result<AddReport, AddError> {
let id = PackId::from(request.path.clone());
let (refdir, ref_action) = match &request.git_ref {
Some(r) => {
let dir = crate::refspec::encode_refdir(r);
let ctx = probe_manifest_for_ref(manifest_path, &request.url, &dir);
let action = crate::refspec::classify_ref_input(r, &ctx);
(Some(dir), Some(action))
}
None => (None, None),
};
let is_reject = matches!(ref_action, Some(a) if a.is_reject());
let appended = !opts.dry_run && !is_reject;
if appended {
let ev = Event::Add {
ts: Utc::now(),
id: id.clone(),
url: request.url.clone(),
path: request.path.clone(),
pack_type: request.pack_type.clone(),
schema_version: SCHEMA_VERSION.to_string(),
};
manifest::append_event(manifest_path, &ev)?;
}
Ok(AddReport {
id,
url: request.url,
path: request.path,
pack_type: request.pack_type,
dry_run: opts.dry_run,
appended,
refdir,
ref_action,
})
}
pub fn infer_path_from_url(url: &str) -> String {
let trimmed = url.trim_end_matches(['/', '\\']);
let tail = trimmed.rsplit(['/', '\\', ':']).next().unwrap_or(trimmed);
tail.strip_suffix(".git").unwrap_or(tail).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_pack_appends_add_event() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join(".grex/events.jsonl");
let report = add_pack(
&manifest,
AddRequest::new("https://example.com/repo.git", "repo", "scripted"),
AddOpts { dry_run: false },
)
.unwrap();
assert!(report.appended);
assert_eq!(report.id, "repo");
assert!(report.refdir.is_none());
assert!(report.ref_action.is_none());
let events = manifest::read_all(&manifest).unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
Event::Add { id, url, path, pack_type, schema_version, .. } => {
assert_eq!(id, "repo");
assert_eq!(url, "https://example.com/repo.git");
assert_eq!(path, "repo");
assert_eq!(pack_type, "scripted");
assert_eq!(schema_version, SCHEMA_VERSION);
}
_ => panic!("expected add event"),
}
}
#[test]
fn add_pack_dry_run_does_not_write_manifest() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join(".grex/events.jsonl");
let report = add_pack(
&manifest,
AddRequest::new("", "local", "declarative"),
AddOpts { dry_run: true },
)
.unwrap();
assert!(!report.appended);
assert!(!manifest.exists());
}
#[test]
fn b10_add_pack_with_ref_emits_refdir_and_action() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join(".grex/events.jsonl");
let r = crate::refspec::Ref {
branch: Some("main".into()),
commit: Some("a3f9c1d2b8e7".into()),
};
let report = add_pack(
&manifest,
AddRequest::new("https://example.com/repo.git", "repo", "scripted").with_ref(r),
AddOpts { dry_run: false },
)
.unwrap();
assert_eq!(report.refdir.as_deref(), Some("main@a3f9c1d"));
assert_eq!(report.ref_action, Some(crate::refspec::RefAction::Add));
}
#[test]
fn b10_add_same_ref_twice_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join(".grex/events.jsonl");
let url = "https://example.com/repo.git";
let r = crate::refspec::Ref {
branch: Some("main".into()),
commit: Some("a3f9c1d2b8e7".into()),
};
let refdir = crate::refspec::encode_refdir(&r);
let r1 = add_pack(
&manifest,
AddRequest::new(url, refdir.clone(), "scripted").with_ref(r.clone()),
AddOpts { dry_run: false },
)
.unwrap();
assert!(r1.appended);
assert_eq!(r1.ref_action, Some(crate::refspec::RefAction::Add));
let r2 = add_pack(
&manifest,
AddRequest::new(url, refdir.clone(), "scripted").with_ref(r),
AddOpts { dry_run: false },
)
.unwrap();
assert!(!r2.appended, "reject-class must not append manifest event");
assert_eq!(r2.ref_action, Some(crate::refspec::RefAction::WarnReject));
assert_eq!(r2.refdir.as_deref(), Some(refdir.as_str()));
let events = manifest::read_all(&manifest).unwrap();
let add_count = events.iter().filter(|e| matches!(e, Event::Add { .. })).count();
assert_eq!(add_count, 1, "only the first add should have been appended");
}
#[test]
fn b10_add_different_refs_independent() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join(".grex/events.jsonl");
let url = "https://example.com/repo.git";
let r1 = crate::refspec::Ref {
branch: Some("main".into()),
commit: Some("a3f9c1d2b8e7".into()),
};
let r2 = crate::refspec::Ref {
branch: Some("develop".into()),
commit: Some("b4e8d2a1c9f6".into()),
};
let dir1 = crate::refspec::encode_refdir(&r1);
let dir2 = crate::refspec::encode_refdir(&r2);
let rep1 = add_pack(
&manifest,
AddRequest::new(url, dir1.clone(), "scripted").with_ref(r1),
AddOpts { dry_run: false },
)
.unwrap();
assert!(rep1.appended);
assert_eq!(rep1.ref_action, Some(crate::refspec::RefAction::Add));
let rep2 = add_pack(
&manifest,
AddRequest::new(url, dir2.clone(), "scripted").with_ref(r2),
AddOpts { dry_run: false },
)
.unwrap();
assert!(rep2.appended, "distinct refdir must not be reject-class");
assert_eq!(rep2.ref_action, Some(crate::refspec::RefAction::WarnAdd));
assert_ne!(dir1, dir2);
let events = manifest::read_all(&manifest).unwrap();
let add_count = events.iter().filter(|e| matches!(e, Event::Add { .. })).count();
assert_eq!(add_count, 2);
}
#[test]
fn b10_warn_reject_emits_warn_action_no_append() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join(".grex/events.jsonl");
let url = "https://example.com/repo.git";
let r = crate::refspec::Ref { branch: None, commit: Some("a3f9c1d2b8e7".into()) };
let refdir = crate::refspec::encode_refdir(&r);
let _ = add_pack(
&manifest,
AddRequest::new(url, refdir.clone(), "scripted").with_ref(r.clone()),
AddOpts { dry_run: false },
)
.unwrap();
let rep = add_pack(
&manifest,
AddRequest::new(url, refdir.clone(), "scripted").with_ref(r),
AddOpts { dry_run: false },
)
.unwrap();
assert_eq!(rep.ref_action, Some(crate::refspec::RefAction::WarnReject));
assert!(!rep.appended);
assert!(rep.ref_action.unwrap().warns());
}
#[test]
fn b10_silent_reject_default_branch_dup_no_append() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join(".grex/events.jsonl");
let url = "https://example.com/repo.git";
let r = crate::refspec::Ref { branch: None, commit: None };
let refdir = crate::refspec::encode_refdir(&r);
let rep1 = add_pack(
&manifest,
AddRequest::new(url, refdir.clone(), "scripted").with_ref(r.clone()),
AddOpts { dry_run: false },
)
.unwrap();
assert!(rep1.appended);
let rep2 = add_pack(
&manifest,
AddRequest::new(url, refdir.clone(), "scripted").with_ref(r),
AddOpts { dry_run: false },
)
.unwrap();
assert_eq!(rep2.ref_action, Some(crate::refspec::RefAction::SilentReject));
assert!(!rep2.appended);
assert!(!rep2.ref_action.unwrap().warns());
}
#[test]
fn b10_add_pack_without_ref_leaves_refdir_none() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join(".grex/events.jsonl");
let report = add_pack(
&manifest,
AddRequest::new("https://example.com/repo.git", "repo", "scripted"),
AddOpts { dry_run: false },
)
.unwrap();
assert!(report.refdir.is_none());
assert!(report.ref_action.is_none());
}
#[test]
fn infer_path_from_https_git_url() {
assert_eq!(infer_path_from_url("https://example.com/org/repo.git"), "repo");
}
#[test]
fn infer_path_from_scp_like_url() {
assert_eq!(infer_path_from_url("git@example.com:org/repo.git"), "repo");
}
#[test]
fn infer_path_from_trailing_slash() {
assert_eq!(infer_path_from_url("https://example.com/org/repo/"), "repo");
}
#[test]
fn infer_path_from_empty_url_is_empty() {
assert_eq!(infer_path_from_url(""), "");
}
}