use std::path::PathBuf;
use crate::git::list_submodules;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RoleKind {
Parent(usize),
Submod {
parent_basename: String,
sub_path: String,
},
Standalone,
}
impl RoleKind {
pub(crate) fn label(&self) -> String {
match self {
RoleKind::Parent(n) => format!("parent·{n}"),
RoleKind::Submod {
parent_basename: _,
sub_path,
} => {
if let Some(stripped) = sub_path.strip_prefix("web/games/") {
stripped.to_string()
} else {
sub_path.clone()
}
}
RoleKind::Standalone => "standalone".to_string(),
}
}
}
pub(crate) fn classify_roles(rows: &[crate::report::RepoReportRow]) -> Vec<RoleKind> {
let abs_paths: Vec<PathBuf> = rows.iter().map(|r| PathBuf::from(r.repo_path())).collect();
let mut results: Vec<RoleKind> = Vec::with_capacity(rows.len());
for (i, _row) in rows.iter().enumerate() {
let my_path = &abs_paths[i];
let my_basename = my_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let my_subs = list_submodules(my_path);
let parent_role = if !my_subs.is_empty() {
Some(RoleKind::Parent(my_subs.len()))
} else {
None
};
let mut full_path_role: Option<RoleKind> = None;
let mut fallback_role: Option<RoleKind> = None;
for (j, other_row) in rows.iter().enumerate() {
if i == j {
continue;
}
let other_path = &abs_paths[j];
let other_subs = list_submodules(other_path);
for entry in &other_subs {
let expected_full = other_path.join(&entry.path);
let full_path_matches = expected_full == *my_path;
let name_matches = entry.name == my_basename;
let last_segment = entry.path.rsplit('/').next().unwrap_or(&entry.path);
let path_tail_matches = !my_basename.is_empty() && last_segment == my_basename;
let parent_basename = other_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| other_row.repo_path().to_string());
let role = RoleKind::Submod {
parent_basename,
sub_path: entry.path.clone(),
};
if full_path_matches {
full_path_role = Some(role);
break;
} else if fallback_role.is_none() && (name_matches || path_tail_matches) {
fallback_role = Some(role);
}
}
if full_path_role.is_some() {
break;
}
}
let submod_role = full_path_role.or(fallback_role);
let final_role = submod_role.or(parent_role).unwrap_or(RoleKind::Standalone);
results.push(final_role);
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::tempdir;
fn init_repo(path: &Path) -> String {
Command::new("git")
.args(["init", "-q", "--initial-branch=main"])
.arg(path)
.output()
.expect("git init");
Command::new("git")
.args(["-C"])
.arg(path)
.args(["config", "core.hooksPath", "/dev/null"])
.output()
.expect("git config core.hooksPath");
Command::new("git")
.args(["-C"])
.arg(path)
.args(["config", "user.email", "test@example.com"])
.output()
.expect("git config user.email");
Command::new("git")
.args(["-C"])
.arg(path)
.args(["config", "user.name", "Test"])
.output()
.expect("git config user.name");
Command::new("git")
.args(["-C"])
.arg(path)
.args(["commit", "--no-verify", "--allow-empty", "-m", "init", "-q"])
.output()
.expect("git commit");
let head_out = Command::new("git")
.args(["-C"])
.arg(path)
.args(["rev-parse", "HEAD"])
.output()
.expect("git rev-parse");
String::from_utf8_lossy(&head_out.stdout).trim().to_string()
}
fn stage_gitlink(parent: &Path, sub_path: &str, sha: &str) {
let status = Command::new("git")
.args(["-C"])
.arg(parent)
.args(["update-index", "--add", "--cacheinfo"])
.arg(format!("160000,{},{}", sha, sub_path))
.status()
.expect("git update-index");
assert!(status.success(), "git update-index failed for {sub_path}");
}
#[test]
fn classify_role_for_standalone_repo() {
let tmp = tempdir().unwrap();
let repo = tmp.path().join("standalone");
fs::create_dir_all(&repo).unwrap();
init_repo(&repo);
let row = crate::report::RepoReportRow::for_tests(&repo.display().to_string());
let rows = vec![row];
let roles = classify_roles(&rows);
assert_eq!(roles.len(), 1);
assert_eq!(roles[0], RoleKind::Standalone);
assert_eq!(roles[0].label(), "standalone");
}
#[test]
fn classify_role_for_parent_repo() {
let tmp = tempdir().unwrap();
let parent_path = tmp.path().join("myparent");
fs::create_dir_all(&parent_path).unwrap();
let head = init_repo(&parent_path);
let gitmodules = "[submodule \"child-a\"]\n\
\tpath = sub/a\n\
\turl = git@example.com:a.git\n\
[submodule \"child-b\"]\n\
\tpath = sub/b\n\
\turl = git@example.com:b.git\n\
[submodule \"child-c\"]\n\
\tpath = sub/c\n\
\turl = git@example.com:c.git\n";
fs::write(parent_path.join(".gitmodules"), gitmodules).unwrap();
stage_gitlink(&parent_path, "sub/a", &head);
stage_gitlink(&parent_path, "sub/b", &head);
stage_gitlink(&parent_path, "sub/c", &head);
let row = crate::report::RepoReportRow::for_tests(&parent_path.display().to_string());
let rows = vec![row];
let roles = classify_roles(&rows);
assert_eq!(roles.len(), 1);
assert_eq!(roles[0], RoleKind::Parent(3));
assert_eq!(roles[0].label(), "parent·3");
}
#[test]
fn classify_role_for_submod_repo() {
let tmp = tempdir().unwrap();
let parent_path = tmp.path().join("myparent");
fs::create_dir_all(&parent_path).unwrap();
let head = init_repo(&parent_path);
let gitmodules = "[submodule \"child\"]\n\
\tpath = sub/child\n\
\turl = git@example.com:child.git\n";
fs::write(parent_path.join(".gitmodules"), gitmodules).unwrap();
stage_gitlink(&parent_path, "sub/child", &head);
let child_dir = parent_path.join("sub/child");
fs::create_dir_all(&child_dir).unwrap();
init_repo(&child_dir);
let row_parent =
crate::report::RepoReportRow::for_tests(&parent_path.display().to_string());
let row_child = crate::report::RepoReportRow::for_tests(&child_dir.display().to_string());
let rows = vec![row_parent, row_child];
let roles = classify_roles(&rows);
assert_eq!(roles.len(), 2);
assert_eq!(roles[0], RoleKind::Parent(1));
match &roles[1] {
RoleKind::Submod {
parent_basename,
sub_path,
} => {
assert_eq!(parent_basename, "myparent");
assert_eq!(sub_path, "sub/child");
}
other => panic!("expected Submod, got {:?}", other),
}
}
#[test]
fn priority_submod_over_parent_when_dual_role() {
let tmp = tempdir().unwrap();
let grand = tmp.path().join("grand");
fs::create_dir_all(&grand).unwrap();
let head = init_repo(&grand);
let grand_gitmodules = "[submodule \"middle\"]\n\
\tpath = sub/middle\n\
\turl = git@example.com:middle.git\n";
fs::write(grand.join(".gitmodules"), grand_gitmodules).unwrap();
stage_gitlink(&grand, "sub/middle", &head);
let middle = grand.join("sub/middle");
fs::create_dir_all(&middle).unwrap();
let middle_head = init_repo(&middle);
let middle_gitmodules = "[submodule \"leaf\"]\n\
\tpath = leaf\n\
\turl = git@example.com:leaf.git\n";
fs::write(middle.join(".gitmodules"), middle_gitmodules).unwrap();
stage_gitlink(&middle, "leaf", &middle_head);
let leaf = middle.join("leaf");
fs::create_dir_all(&leaf).unwrap();
init_repo(&leaf);
let rows = vec![
crate::report::RepoReportRow::for_tests(&grand.display().to_string()),
crate::report::RepoReportRow::for_tests(&middle.display().to_string()),
crate::report::RepoReportRow::for_tests(&leaf.display().to_string()),
];
let roles = classify_roles(&rows);
assert_eq!(roles[0], RoleKind::Parent(1));
match &roles[1] {
RoleKind::Submod {
parent_basename,
sub_path,
} => {
assert_eq!(parent_basename, "grand");
assert_eq!(sub_path, "sub/middle");
}
other => panic!("expected Submod for middle, got {:?}", other),
}
match &roles[2] {
RoleKind::Submod {
parent_basename,
sub_path,
} => {
assert_eq!(parent_basename, "middle");
assert_eq!(sub_path, "leaf");
}
other => panic!("expected Submod for leaf, got {:?}", other),
}
}
#[test]
fn f55_full_path_distinguishes_same_basename_repos() {
let dir = tempdir().unwrap();
let parent = dir.path().join("parent");
let sibling = dir.path().join("sibling-foo");
let nested = parent.join("nested-foo");
fs::create_dir_all(&parent).unwrap();
fs::create_dir_all(&sibling).unwrap();
fs::create_dir_all(&nested).unwrap();
fs::write(
parent.join(".gitmodules"),
"[submodule \"nested-foo\"]\n\tpath = nested-foo\n\turl = https://example.com/foo.git\n"
).unwrap();
let rows = vec![
crate::report::RepoReportRow::for_tests(&parent.to_string_lossy()),
crate::report::RepoReportRow::for_tests(&sibling.to_string_lossy()),
crate::report::RepoReportRow::for_tests(&nested.to_string_lossy()),
];
let roles = classify_roles(&rows);
assert!(matches!(roles[0], RoleKind::Parent(1)));
assert!(
matches!(roles[1], RoleKind::Standalone),
"expected standalone, got {:?}",
roles[1]
);
assert!(
matches!(roles[2], RoleKind::Submod { .. }),
"expected submod, got {:?}",
roles[2]
);
}
#[test]
fn full_path_match_beats_earlier_basename_fallback() {
let dir = tempdir().unwrap();
let fallback_parent = dir.path().join("fallback-parent");
let actual_parent = dir.path().join("actual-parent");
let target = actual_parent.join("nested/target");
for path in [&fallback_parent, &actual_parent, &target] {
fs::create_dir_all(path).unwrap();
}
let fallback_head = init_repo(&fallback_parent);
let actual_head = init_repo(&actual_parent);
init_repo(&target);
fs::write(
fallback_parent.join(".gitmodules"),
"[submodule \"target\"]\n\tpath = other/target\n\turl = example:target.git\n",
)
.unwrap();
stage_gitlink(&fallback_parent, "other/target", &fallback_head);
fs::write(
actual_parent.join(".gitmodules"),
"[submodule \"target\"]\n\tpath = nested/target\n\turl = example:target.git\n",
)
.unwrap();
stage_gitlink(&actual_parent, "nested/target", &actual_head);
let rows = vec![
crate::report::RepoReportRow::for_tests(&fallback_parent.display().to_string()),
crate::report::RepoReportRow::for_tests(&actual_parent.display().to_string()),
crate::report::RepoReportRow::for_tests(&target.display().to_string()),
];
let roles = classify_roles(&rows);
match &roles[2] {
RoleKind::Submod {
parent_basename,
sub_path,
} => {
assert_eq!(parent_basename, "actual-parent");
assert_eq!(sub_path, "nested/target");
}
other => panic!("expected exact-path submod, got {:?}", other),
}
}
#[test]
fn label_compact_submod_strips_web_games_prefix() {
let r = RoleKind::Submod {
parent_basename: "dracon-platform".to_string(),
sub_path: "web/games/wip/hegemon".to_string(),
};
assert_eq!(r.label(), "wip/hegemon");
}
#[test]
fn label_compact_submod_keeps_released_tier() {
let r = RoleKind::Submod {
parent_basename: "dracon-platform".to_string(),
sub_path: "web/games/released/one-mil-girls".to_string(),
};
assert_eq!(r.label(), "released/one-mil-girls");
}
#[test]
fn label_compact_submod_falls_back_when_no_web_games_prefix() {
let r = RoleKind::Submod {
parent_basename: "myparent".to_string(),
sub_path: "packages/my-sub".to_string(),
};
assert_eq!(r.label(), "packages/my-sub");
}
#[test]
fn label_parent_unchanged() {
assert_eq!(RoleKind::Parent(10).label(), "parent·10");
assert_eq!(RoleKind::Parent(1).label(), "parent·1");
}
#[test]
fn label_standalone_unchanged() {
assert_eq!(RoleKind::Standalone.label(), "standalone");
}
}