use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OwnershipReport {
Owned { reason: String },
Unowned { reason: String, detail: String },
Unknown { detail: String },
}
impl OwnershipReport {
pub fn label(&self) -> String {
match self {
OwnershipReport::Owned { reason } => format!("✓ owned ({})", reason),
OwnershipReport::Unowned { reason, detail } => {
let trimmed = truncate(detail, 60);
format!("🚫 unowned: {} ({})", reason, trimmed)
}
OwnershipReport::Unknown { detail } => {
let trimmed = truncate(detail, 60);
format!("❓ unknown: {}", trimmed)
}
}
}
pub fn hint(&self) -> &'static str {
match self {
OwnershipReport::Owned { .. } => "owned by operator",
OwnershipReport::Unowned { .. } | OwnershipReport::Unknown { .. } => {
"repo not owned by operator (run ownership --explain <repo>)"
}
}
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
#[derive(Debug, Clone)]
pub struct OwnershipInputs {
pub user_email: Option<String>,
pub head_author_email: Option<String>,
pub head_author_name: Option<String>,
pub origin_url: Option<String>,
pub override_owned: Option<bool>,
}
pub fn classify(inputs: &OwnershipInputs, trusted: &TrustedSet) -> OwnershipReport {
if let Some(forced) = inputs.override_owned {
return if forced {
OwnershipReport::Owned {
reason: "override".to_string(),
}
} else {
OwnershipReport::Unowned {
reason: "override".to_string(),
detail: "RepoPolicyOverride.owned = false".to_string(),
}
};
}
let have_user_email = inputs.user_email.is_some();
let have_head = inputs.head_author_email.is_some()
|| inputs.head_author_name.is_some();
let have_origin = inputs.origin_url.is_some();
if let Some(ref email) = inputs.user_email {
if !trusted.emails.iter().any(|e| e == email) {
return OwnershipReport::Unowned {
reason: "untrusted_email".to_string(),
detail: format!("git config user.email = {}", email),
};
}
}
if have_head {
let head_email_trusted = inputs
.head_author_email
.as_ref()
.map(|e| trusted.emails.iter().any(|t| t == e))
.unwrap_or(false);
let head_name_trusted = inputs
.head_author_name
.as_ref()
.map(|n| trusted.authors.iter().any(|t| t == n))
.unwrap_or(false);
if !head_email_trusted && !head_name_trusted {
let detail = match (&inputs.head_author_email, &inputs.head_author_name) {
(Some(e), Some(n)) => format!("HEAD author = {} <{}>", n, e),
(Some(e), None) => format!("HEAD author email = {}", e),
(None, Some(n)) => format!("HEAD author name = {}", n),
(None, None) => "no HEAD author".to_string(),
};
return OwnershipReport::Unowned {
reason: "untrusted_author".to_string(),
detail,
};
}
}
if have_origin {
let url = inputs.origin_url.as_ref().unwrap();
if !is_trusted_origin(url, &trusted.remote_hosts) {
return OwnershipReport::Unowned {
reason: "untrusted_origin".to_string(),
detail: format!("origin = {}", url),
};
}
}
if have_user_email {
return OwnershipReport::Owned {
reason: "trusted_email".to_string(),
};
}
if have_head {
return OwnershipReport::Owned {
reason: "trusted_author".to_string(),
};
}
if have_origin {
return OwnershipReport::Owned {
reason: "trusted_origin".to_string(),
};
}
OwnershipReport::Unknown {
detail: "no signals available (no user.email, no HEAD, no origin)"
.to_string(),
}
}
fn is_trusted_origin(url: &str, trusted_hosts: &[String]) -> bool {
if trusted_hosts.is_empty() {
return false;
}
let normalized = if let Some(idx) = url.find('@') {
if let Some(colon_idx) = url[idx..].find(':') {
let host = &url[idx + 1..idx + colon_idx];
let path = &url[idx + colon_idx + 1..];
format!("{}/{}", host, path)
} else {
url.to_string()
}
} else {
url.to_string()
};
trusted_hosts.iter().any(|h| normalized.contains(h))
}
#[derive(Debug, Clone, Default)]
pub struct TrustedSet {
pub emails: Vec<String>,
pub authors: Vec<String>,
pub remote_hosts: Vec<String>,
}
pub fn read_signals(repo: &Path) -> OwnershipInputs {
OwnershipInputs {
user_email: git_config_user_email(repo),
head_author_email: git_head_author_email(repo),
head_author_name: git_head_author_name(repo),
origin_url: git_origin_url(repo),
override_owned: None,
}
}
fn git_config_user_email(repo: &Path) -> Option<String> {
let out = Command::new("git")
.args(["config", "--get", "user.email"])
.current_dir(repo)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn git_head_author_email(repo: &Path) -> Option<String> {
let out = Command::new("git")
.args(["log", "-1", "--pretty=%ae"])
.current_dir(repo)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn git_head_author_name(repo: &Path) -> Option<String> {
let out = Command::new("git")
.args(["log", "-1", "--pretty=%an"])
.current_dir(repo)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn git_origin_url(repo: &Path) -> Option<String> {
let out = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(repo)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
pub fn detect_ownership(
repo: &Path,
trusted: &TrustedSet,
override_owned: Option<bool>,
) -> OwnershipReport {
let mut inputs = read_signals(repo);
inputs.override_owned = override_owned;
classify(&inputs, trusted)
}
#[cfg(test)]
mod tests {
use super::*;
fn default_trusted() -> TrustedSet {
TrustedSet {
emails: vec!["dracsharp@gmail.com".to_string()],
authors: vec!["DraconDev".to_string()],
remote_hosts: vec![
"github.com/DraconDev".to_string(),
"gitlab.com/dracondev".to_string(),
"codeberg.org/dracondev".to_string(),
],
}
}
#[test]
fn test_classify_trusted_email_matches() {
let inputs = OwnershipInputs {
user_email: Some("dracsharp@gmail.com".to_string()),
head_author_email: Some("dracsharp@gmail.com".to_string()),
head_author_name: Some("DraconDev".to_string()),
origin_url: Some("git@github.com:DraconDev/repo.git".to_string()),
override_owned: None,
};
let report = classify(&inputs, &default_trusted());
assert!(matches!(report, OwnershipReport::Owned { .. }));
if let OwnershipReport::Owned { reason } = report {
assert_eq!(reason, "trusted_email");
}
}
#[test]
fn test_classify_unowned_user_email() {
let inputs = OwnershipInputs {
user_email: Some("dracon@void".to_string()),
head_author_email: Some("dracsharp@gmail.com".to_string()),
head_author_name: Some("DraconDev".to_string()),
origin_url: Some("git@github.com:DraconDev/repo.git".to_string()),
override_owned: None,
};
let report = classify(&inputs, &default_trusted());
match report {
OwnershipReport::Unowned { reason, detail } => {
assert_eq!(reason, "untrusted_email");
assert!(detail.contains("dracon@void"));
}
other => panic!("expected Unowned, got {:?}", other),
}
}
#[test]
fn test_classify_unowned_origin_url() {
let inputs = OwnershipInputs {
user_email: Some("dracsharp@gmail.com".to_string()),
head_author_email: Some("dracsharp@gmail.com".to_string()),
head_author_name: Some("DraconDev".to_string()),
origin_url: Some("https://github.com/gi-dellav/zerostack.git".to_string()),
override_owned: None,
};
let report = classify(&inputs, &default_trusted());
match report {
OwnershipReport::Unowned { reason, detail } => {
assert_eq!(reason, "untrusted_origin");
assert!(detail.contains("gi-dellav"));
}
other => panic!("expected Unowned, got {:?}", other),
}
}
#[test]
fn test_classify_unowned_head_author() {
let inputs = OwnershipInputs {
user_email: Some("dracsharp@gmail.com".to_string()),
head_author_email: Some("dracon@void".to_string()),
head_author_name: Some("Dracon".to_string()),
origin_url: Some("git@github.com:DraconDev/dracon-ai-lib.git".to_string()),
override_owned: None,
};
let report = classify(&inputs, &default_trusted());
match report {
OwnershipReport::Unowned { reason, detail } => {
assert_eq!(reason, "untrusted_author");
assert!(detail.contains("Dracon"));
}
other => panic!("expected Unowned, got {:?}", other),
}
}
#[test]
fn test_classify_per_repo_override_owned() {
let inputs = OwnershipInputs {
user_email: Some("dracon@void".to_string()),
head_author_email: Some("dracon@void".to_string()),
head_author_name: Some("Dracon".to_string()),
origin_url: Some("https://github.com/gi-dellav/zerostack.git".to_string()),
override_owned: Some(true),
};
let report = classify(&inputs, &default_trusted());
match report {
OwnershipReport::Owned { reason } => assert_eq!(reason, "override"),
other => panic!("expected Owned, got {:?}", other),
}
}
#[test]
fn test_classify_per_repo_override_unowned() {
let inputs = OwnershipInputs {
user_email: Some("dracsharp@gmail.com".to_string()),
head_author_email: Some("dracsharp@gmail.com".to_string()),
head_author_name: Some("DraconDev".to_string()),
origin_url: Some("git@github.com:DraconDev/repo.git".to_string()),
override_owned: Some(false),
};
let report = classify(&inputs, &default_trusted());
match report {
OwnershipReport::Unowned { reason, .. } => assert_eq!(reason, "override"),
other => panic!("expected Unowned, got {:?}", other),
}
}
#[test]
fn test_classify_unknown_no_signals() {
let inputs = OwnershipInputs {
user_email: None,
head_author_email: None,
head_author_name: None,
origin_url: None,
override_owned: None,
};
let report = classify(&inputs, &default_trusted());
match report {
OwnershipReport::Unknown { detail } => {
assert!(detail.contains("no signals"));
}
other => panic!("expected Unknown, got {:?}", other),
}
}
#[test]
fn test_classify_trusted_origin_only() {
let inputs = OwnershipInputs {
user_email: None,
head_author_email: None,
head_author_name: None,
origin_url: Some("git@github.com:DraconDev/fresh.git".to_string()),
override_owned: None,
};
let report = classify(&inputs, &default_trusted());
match report {
OwnershipReport::Owned { reason } => assert_eq!(reason, "trusted_origin"),
other => panic!("expected Owned, got {:?}", other),
}
}
#[test]
fn test_is_trusted_origin_substring() {
let hosts = vec!["github.com/DraconDev".to_string()];
assert!(is_trusted_origin(
"https://github.com/DraconDev/repo.git",
&hosts
));
assert!(is_trusted_origin(
"git@github.com:DraconDev/repo.git",
&hosts
));
assert!(!is_trusted_origin(
"https://github.com/gi-dellav/repo.git",
&hosts
));
}
#[test]
fn test_is_trusted_origin_empty_hosts() {
let hosts: Vec<String> = vec![];
assert!(!is_trusted_origin("https://github.com/DraconDev/r.git", &hosts));
}
#[test]
fn test_label_format() {
let owned = OwnershipReport::Owned {
reason: "trusted_email".to_string(),
};
assert!(owned.label().contains("owned"));
assert!(owned.label().contains("trusted_email"));
let unowned = OwnershipReport::Unowned {
reason: "untrusted_origin".to_string(),
detail: "origin = https://github.com/gi-dellav/zerostack.git".to_string(),
};
assert!(unowned.label().contains("🚫"));
assert!(unowned.label().contains("untrusted_origin"));
}
}