use serde::{Deserialize, Serialize};
use crate::core::trusty_tools_config::GithubConfig;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Project {
pub name: String,
pub repo_url: String,
pub default_branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stack_hint: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gh_user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gh_account: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GithubConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit_email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<bool>,
}
impl Project {
pub fn worktree_enabled(&self) -> bool {
self.worktree.unwrap_or(true)
}
}
pub fn repo_url_matches(a: &str, b: &str) -> bool {
match (
trusty_common::github_path::parse_github_path(a),
trusty_common::github_path::parse_github_path(b),
) {
(Some(pa), Some(pb)) => {
pa.owner.eq_ignore_ascii_case(&pb.owner) && pa.repo.eq_ignore_ascii_case(&pb.repo)
}
_ => normalise_repo_url(a) == normalise_repo_url(b),
}
}
fn normalise_repo_url(url: &str) -> String {
url.trim_end_matches('/')
.trim_end_matches(".git")
.to_lowercase()
}
pub fn derive_name_from_url(repo_url: &str) -> Option<String> {
if !repo_url.contains("://") && repo_url.contains(':') {
let path_part = repo_url.split_once(':')?.1;
let segment = path_part.trim_end_matches('/').split('/').next_back()?;
if segment.is_empty() {
return None;
}
return Some(segment.strip_suffix(".git").unwrap_or(segment).to_string());
}
let segments: Vec<&str> = repo_url.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() < 3 {
return None;
}
let segment = segments.last()?;
Some(segment.strip_suffix(".git").unwrap_or(segment).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_serde_round_trip() {
let p = Project {
name: "trusty-tools".into(),
repo_url: "https://github.com/owner/trusty-tools".into(),
default_branch: "main".into(),
stack_hint: Some("rust".into()),
tags: vec!["backend".into(), "oss".into()],
description: Some("the unified trusty workspace".into()),
gh_user: Some("bobmatnyc".into()),
gh_account: Some("bobmatnyc".into()),
github: Some(GithubConfig {
config_dir: Some("/home/bob/.config/gh-work".into()),
token_env: None,
account: None,
host: None,
}),
commit_name: Some("Bob".into()),
commit_email: Some("bob@example.com".into()),
worktree: Some(false),
};
let json = serde_json::to_string(&p).expect("serialize");
assert!(json.contains("gh_user"), "json: {json}");
assert!(json.contains("gh_account"), "json: {json}");
assert!(json.contains("github"), "json: {json}");
assert!(json.contains("commit_name"), "json: {json}");
assert!(json.contains("commit_email"), "json: {json}");
assert!(json.contains("worktree"), "json: {json}");
let back: Project = serde_json::from_str(&json).expect("deserialize");
assert_eq!(p, back);
}
#[test]
fn project_without_optionals() {
let p = Project {
name: "minimal".into(),
repo_url: "https://github.com/owner/minimal".into(),
default_branch: "main".into(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: None,
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
};
let json = serde_json::to_string(&p).expect("serialize");
assert!(
!json.contains("stack_hint"),
"absent optional must not serialise: {json}"
);
assert!(
!json.contains("worktree"),
"absent worktree must not serialise: {json}"
);
assert!(
!json.contains("gh_user"),
"absent gh_user must not serialise: {json}"
);
assert!(
!json.contains("gh_account"),
"absent gh_account must not serialise: {json}"
);
assert!(
!json.contains("tags"),
"empty tags must not serialise: {json}"
);
assert!(
!json.contains("\"github\":"),
"absent github binding must not serialise: {json}"
);
assert!(
!json.contains("commit_name") && !json.contains("commit_email"),
"absent commit identity must not serialise: {json}"
);
let back: Project = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.stack_hint, None);
assert!(back.tags.is_empty());
}
#[test]
fn repo_url_matches_https_vs_git_suffix() {
assert!(repo_url_matches(
"https://github.com/acme/widget",
"https://github.com/acme/widget.git"
));
}
#[test]
fn repo_url_matches_ssh_form() {
assert!(repo_url_matches(
"git@github.com:acme/widget.git",
"https://github.com/acme/widget"
));
}
#[test]
fn repo_url_matches_case_insensitive() {
assert!(repo_url_matches(
"https://github.com/Acme/Widget",
"https://github.com/acme/widget"
));
}
#[test]
fn repo_url_matches_rejects_different_repo() {
assert!(!repo_url_matches(
"https://github.com/acme/widget",
"https://github.com/acme/gadget"
));
}
#[test]
fn repo_url_matches_falls_back_to_string_compare() {
assert!(repo_url_matches("", ""));
assert!(!repo_url_matches("", "https://github.com/acme/widget"));
assert!(!repo_url_matches(
"https://github.com/",
"https://gitlab.com/"
));
}
#[test]
fn derive_name_from_url_basic() {
assert_eq!(
derive_name_from_url("https://github.com/owner/my-repo"),
Some("my-repo".into())
);
}
#[test]
fn derive_name_from_url_with_git_suffix() {
assert_eq!(
derive_name_from_url("https://github.com/owner/trusty-tools.git"),
Some("trusty-tools".into())
);
}
#[test]
fn derive_name_from_url_ssh() {
assert_eq!(
derive_name_from_url("git@github.com:owner/repo.git"),
Some("repo".into())
);
}
#[test]
fn derive_name_from_url_trailing_slash() {
assert_eq!(
derive_name_from_url("https://github.com/owner/repo/"),
Some("repo".into())
);
}
#[test]
fn derive_name_from_url_no_path() {
assert_eq!(derive_name_from_url("https://github.com/"), None);
}
fn minimal_project() -> Project {
Project {
name: "widget".into(),
repo_url: "https://github.com/acme/widget".into(),
default_branch: "main".into(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: None,
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
#[test]
fn worktree_enabled_defaults_true() {
assert!(minimal_project().worktree_enabled());
}
#[test]
fn worktree_enabled_honors_false() {
let mut p = minimal_project();
p.worktree = Some(false);
assert!(!p.worktree_enabled());
}
}