use chrono::DateTime;
use gix::bstr::ByteSlice;
use sha2::{Digest, Sha256};
use crate::fetcher::{FetchContext, FetchError};
use crate::payload::{Body, Payload};
pub fn payload(body: Body) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body,
}
}
pub fn parse_options<T: serde::de::DeserializeOwned + Default>(
raw: Option<&toml::Value>,
) -> Result<T, String> {
match raw {
None => Ok(T::default()),
Some(value) => value
.clone()
.try_into::<T>()
.map_err(|e| format!("invalid options: {e}")),
}
}
pub fn parse_timestamp(raw: &str) -> i64 {
DateTime::parse_from_rfc3339(raw)
.map(|dt| dt.timestamp())
.unwrap_or(0)
}
pub fn cache_key(name: &str, ctx: &FetchContext, extra: &str) -> String {
let shape = ctx.shape.map(|s| s.as_str()).unwrap_or("default");
let raw = format!(
"{}|{}|{}|{}",
name,
shape,
ctx.format.as_deref().unwrap_or(""),
extra
);
let digest = Sha256::digest(raw.as_bytes());
let hex: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
format!("{name}-{hex}")
}
pub fn validate_host(host: &str) -> Result<&str, String> {
let too_short_or_long = host.is_empty() || host.len() > 253;
let bad_chars = host
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || c == '.' || c == '-'));
let bad_anchor = host.starts_with('.')
|| host.ends_with('.')
|| host.starts_with('-')
|| host.ends_with('-');
let empty_label = host.contains("..");
if too_short_or_long || bad_chars || bad_anchor || empty_label {
return Err(format!("invalid gitlab host: {host:?}"));
}
Ok(host)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectPath(String);
impl ProjectPath {
pub fn parse(raw: &str) -> Option<Self> {
let trimmed = raw.trim().trim_matches('/');
let segments: Vec<&str> = trimmed.split('/').collect();
let valid = !trimmed.is_empty()
&& segments.len() >= 2
&& segments.iter().all(|seg| {
!seg.is_empty()
&& seg
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
});
valid.then(|| Self(trimmed.into()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn encoded(&self) -> String {
self.0.replace('/', "%2F")
}
pub fn name(&self) -> &str {
self.0.rsplit('/').next().unwrap_or(self.as_str())
}
}
pub fn resolve_project(explicit: Option<&str>, host: &str) -> Result<ProjectPath, FetchError> {
if let Some(raw) = explicit {
return ProjectPath::parse(raw)
.ok_or_else(|| FetchError::Failed(format!("invalid project option: {raw:?}")));
}
match remote_project_from_cwd(host) {
Some(path) => Ok(path),
None => Err(FetchError::Failed(
"no project: set `project = \"group/name\"` or run inside a git repo with a gitlab \
remote matching `host`"
.into(),
)),
}
}
fn remote_project_from_cwd(host: &str) -> Option<ProjectPath> {
let cwd = std::env::current_dir().ok()?;
let repo = gix::discover(&cwd).ok()?;
let names: Vec<_> = repo.remote_names().into_iter().collect();
let preferred = names
.iter()
.find(|n| n.as_bstr() == "origin")
.or_else(|| names.first())?;
let remote = repo.find_remote(preferred).ok()?;
let url = remote.url(gix::remote::Direction::Fetch)?;
project_from_url(&url.to_bstring().to_string(), host)
}
pub fn project_from_url(url: &str, host: &str) -> Option<ProjectPath> {
let host_ssh = format!("git@{host}:");
let host_https = format!("https://{host}/");
let host_ssh_scheme = format!("ssh://git@{host}/");
let rest = url
.strip_prefix(&host_ssh)
.or_else(|| strip_https_userinfo(url, host))
.or_else(|| url.strip_prefix(&host_https))
.or_else(|| url.strip_prefix(&host_ssh_scheme))?;
let rest = rest.strip_suffix(".git").unwrap_or(rest);
ProjectPath::parse(rest)
}
fn strip_https_userinfo<'a>(url: &'a str, host: &str) -> Option<&'a str> {
let after_scheme = url.strip_prefix("https://")?;
let (userinfo, after_userinfo) = after_scheme.split_once('@')?;
if userinfo.contains('/') {
return None;
}
let host_slash = format!("{host}/");
after_userinfo.strip_prefix(&host_slash)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_host_accepts_canonical_gitlab_dot_com() {
assert_eq!(validate_host("gitlab.com").unwrap(), "gitlab.com");
}
#[test]
fn validate_host_accepts_self_hosted_subdomain() {
assert_eq!(
validate_host("git.example-corp.io").unwrap(),
"git.example-corp.io"
);
}
#[test]
fn validate_host_rejects_schemes_and_paths() {
assert!(validate_host("https://gitlab.com").is_err());
assert!(validate_host("gitlab.com/api").is_err());
assert!(validate_host("gitlab.com:8080").is_err());
}
#[test]
fn validate_host_rejects_structural_garbage() {
assert!(validate_host("").is_err());
assert!(validate_host(".gitlab.com").is_err());
assert!(validate_host("gitlab.com.").is_err());
assert!(validate_host("-gitlab.com").is_err());
assert!(validate_host("gitlab..com").is_err());
assert!(validate_host("gitlab com").is_err());
}
#[test]
fn project_path_parses_two_and_three_segments() {
let two = ProjectPath::parse("group/proj").unwrap();
assert_eq!(two.as_str(), "group/proj");
assert_eq!(two.encoded(), "group%2Fproj");
assert_eq!(two.name(), "proj");
let three = ProjectPath::parse("group/sub/proj").unwrap();
assert_eq!(three.encoded(), "group%2Fsub%2Fproj");
assert_eq!(three.name(), "proj");
}
#[test]
fn project_path_trims_leading_and_trailing_slashes() {
let p = ProjectPath::parse("/group/proj/").unwrap();
assert_eq!(p.as_str(), "group/proj");
}
#[test]
fn project_path_rejects_invalid_segments() {
assert!(ProjectPath::parse("").is_none());
assert!(ProjectPath::parse("/").is_none());
assert!(ProjectPath::parse("group/").is_none());
assert!(ProjectPath::parse("group//proj").is_none());
assert!(ProjectPath::parse("group/proj?weird").is_none());
assert!(ProjectPath::parse("group/proj space").is_none());
}
#[test]
fn project_from_url_matches_each_documented_shape() {
let host = "gitlab.com";
for url in [
"git@gitlab.com:unhappychoice/splashboard.git",
"https://gitlab.com/unhappychoice/splashboard",
"https://gitlab.com/unhappychoice/splashboard.git",
"https://fdncred@gitlab.com/unhappychoice/splashboard.git",
"https://user:tok@gitlab.com/unhappychoice/splashboard",
"ssh://git@gitlab.com/unhappychoice/splashboard.git",
] {
let p = project_from_url(url, host).unwrap_or_else(|| panic!("could not parse {url}"));
assert_eq!(p.as_str(), "unhappychoice/splashboard", "url={url}");
}
}
#[test]
fn project_from_url_carries_subgroups_through() {
let p = project_from_url("git@gitlab.com:my-group/sub/proj.git", "gitlab.com").unwrap();
assert_eq!(p.as_str(), "my-group/sub/proj");
}
#[test]
fn project_from_url_rejects_non_matching_hosts() {
assert!(
project_from_url("git@github.com:unhappychoice/splashboard.git", "gitlab.com")
.is_none()
);
assert!(project_from_url("https://gitlab.example.org/u/proj.git", "gitlab.com").is_none());
}
#[test]
fn parse_timestamp_reads_rfc3339_with_fractions() {
assert_eq!(parse_timestamp("2026-04-22T10:15:30Z"), 1_776_852_930);
assert_eq!(parse_timestamp("2026-04-22T10:15:30.123Z"), 1_776_852_930);
}
#[test]
fn parse_timestamp_falls_back_to_zero_on_garbage() {
assert_eq!(parse_timestamp("not-a-date"), 0);
}
#[test]
fn cache_key_changes_with_extra_and_shape() {
let ctx = FetchContext {
shape: Some(crate::render::Shape::Entries),
..Default::default()
};
let a = cache_key("gitlab_repo_mrs", &ctx, "gitlab.com|group/proj");
let b = cache_key("gitlab_repo_mrs", &ctx, "gitlab.com|group/proj2");
assert_ne!(a, b);
assert!(a.starts_with("gitlab_repo_mrs-"));
}
#[test]
fn parse_options_returns_default_when_no_table_is_present() {
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Opts {
#[serde(default)]
host: Option<String>,
}
let opts: Opts = parse_options(None).unwrap();
assert_eq!(opts, Opts::default());
}
#[test]
fn payload_wraps_body_and_leaves_all_metadata_unset() {
let p = payload(Body::Text(crate::payload::TextData {
value: "hello".into(),
}));
assert!(p.icon.is_none());
assert!(p.status.is_none());
assert!(p.format.is_none());
assert!(matches!(p.body, Body::Text(t) if t.value == "hello"));
}
#[test]
fn project_from_url_rejects_userinfo_that_contains_a_slash() {
assert!(project_from_url("https://a/b@gitlab.com/group/proj", "gitlab.com").is_none());
}
#[test]
fn resolve_project_falls_back_to_the_cwd_git_remote() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(env!("CARGO_MANIFEST_DIR")).unwrap();
let resolved = resolve_project(None, "github.com");
std::env::set_current_dir(previous).unwrap();
let path = resolved.expect("cwd repo remote should resolve a project");
let segments: Vec<&str> = path.as_str().split('/').collect();
assert!(
segments.len() >= 2 && segments.iter().all(|s| !s.is_empty()),
"expected a `group/project` path, got {:?}",
path.as_str()
);
}
#[test]
fn parse_options_surfaces_serde_failures() {
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Opts {
#[serde(default)]
#[allow(dead_code)]
host: Option<String>,
}
let raw: toml::Value = toml::from_str("bogus = 1").unwrap();
let err = parse_options::<Opts>(Some(&raw)).unwrap_err();
assert!(err.contains("invalid options"));
}
}