cargo-port 0.1.1

A TUI for inspecting and managing Rust projects
use std::collections::HashMap;
use std::path::Path;

use super::checkout::CheckoutInfo;
use super::command;
use super::constants::GIT_HEAD;
use super::constants::GIT_LS_TREE_COMMAND;
use super::constants::GIT_SUBMODULE_BRANCH_KEY;
use super::constants::GIT_SUBMODULE_PATH_KEY;
use super::constants::GIT_SUBMODULE_URL_KEY;
use super::constants::GIT_TREE_SUBMODULE_MODE;
use super::discovery::WorktreeStatus;
use crate::project::GitRepo;
use crate::project::info::ProjectInfo;
use crate::project::info::Visibility;
use crate::project::info::WorktreeHealth;
use crate::project::paths;
use crate::project::paths::AbsolutePath;
use crate::project::paths::DisplayPath;
use crate::project::paths::RootDirectoryName;
use crate::project::project_fields::ProjectFields;

/// A git submodule that participates as a concrete project-list node.
#[derive(Clone)]
pub(crate) struct Submodule {
    /// The submodule name from `.gitmodules` (e.g. `glTF-IBL-Sampler`).
    pub name:          String,
    /// Absolute path on disk.
    pub path:          AbsolutePath,
    /// Relative path within the parent repo (the `path =` value).
    pub relative_path: String,
    /// Remote URL from `.gitmodules`.
    pub url:           Option<String>,
    /// Tracking branch from `.gitmodules` (if specified).
    pub branch:        Option<String>,
    /// Pinned commit SHA from `git ls-tree HEAD`.
    pub commit:        Option<String>,
    /// Shared metadata (git info, disk usage, etc.) — populated by
    /// background messages through the standard `at_path_mut` lookup.
    pub info:          ProjectInfo,
    /// Per-repo data for the submodule's own git repo. `None` when the
    /// submodule's working directory is missing or uninitialized, or
    /// before `RepoInfo::get` has been invoked for this path.
    pub git_repo:      Option<GitRepo>,
}

impl ProjectFields for Submodule {
    fn path(&self) -> &AbsolutePath { &self.path }

    fn name(&self) -> Option<&str> { Some(&self.name) }

    fn visibility(&self) -> Visibility { self.info.visibility }

    fn worktree_health(&self) -> WorktreeHealth { self.info.worktree_health }

    fn disk_usage_bytes(&self) -> Option<u64> { self.info.disk_usage_bytes }

    fn git_info(&self) -> Option<&CheckoutInfo> { self.info.local_git_state.info() }

    fn info(&self) -> &ProjectInfo { &self.info }

    fn display_path(&self) -> DisplayPath { self.path.display_path() }

    fn root_directory_name(&self) -> RootDirectoryName {
        RootDirectoryName(paths::directory_leaf(self.path.as_path()))
    }

    /// Submodules are not treated as worktree roots themselves — their
    /// git-ness is handled inside their parent repo. Always `NotGit`.
    fn worktree_status(&self) -> &WorktreeStatus { &NOT_GIT }
}

static NOT_GIT: WorktreeStatus = WorktreeStatus::NotGit;

/// Parse `.gitmodules` and resolve pinned commits for all submodules.
pub(crate) fn get_submodules(project_root: &Path) -> Vec<Submodule> {
    let gitmodules_path = project_root.join(".gitmodules");
    let Ok(content) = std::fs::read_to_string(&gitmodules_path) else {
        return Vec::new();
    };

    let mut entries = parse_gitmodules(&content);
    if entries.is_empty() {
        return Vec::new();
    }

    // Resolve absolute paths and pinned commits.
    let commits = ls_tree_submodule_commits(project_root);
    for entry in &mut entries {
        entry.path = AbsolutePath::from(project_root.join(&entry.relative_path));
        if let Some(sha) = commits.get(&entry.relative_path) {
            entry.commit = Some(sha.clone());
        }
    }

    entries
}

/// Parse the INI-like `.gitmodules` format into partially filled entries.
fn parse_gitmodules(content: &str) -> Vec<Submodule> {
    let mut entries: Vec<Submodule> = Vec::new();
    let mut current: Option<Submodule> = None;

    for line in content.lines() {
        let trimmed = line.trim();
        if let Some(header) = trimmed
            .strip_prefix("[submodule \"")
            .and_then(|s| s.strip_suffix("\"]"))
        {
            if let Some(entry) = current.take() {
                entries.push(entry);
            }
            current = Some(Submodule {
                name:          header.to_string(),
                path:          "/".into(),
                relative_path: String::new(),
                url:           None,
                branch:        None,
                commit:        None,
                info:          ProjectInfo::default(),
                git_repo:      None,
            });
        } else if let Some(ref mut entry) = current
            && let Some((key, value)) = parse_key_value(trimmed)
        {
            match key {
                GIT_SUBMODULE_PATH_KEY => entry.relative_path = value.to_string(),
                GIT_SUBMODULE_URL_KEY => entry.url = Some(value.to_string()),
                GIT_SUBMODULE_BRANCH_KEY => entry.branch = Some(value.to_string()),
                _ => {},
            }
        }
    }
    if let Some(entry) = current {
        entries.push(entry);
    }

    entries
}

/// Extract `key = value` from a trimmed config line.
fn parse_key_value(line: &str) -> Option<(&str, &str)> {
    let (key, rest) = line.split_once('=')?;
    Some((key.trim(), rest.trim()))
}

/// Run `git ls-tree HEAD` to get pinned commit SHAs for submodule paths.
///
/// Returns a map of `relative_path` → short SHA.
fn ls_tree_submodule_commits(project_root: &Path) -> HashMap<String, String> {
    let mut map = std::collections::HashMap::new();
    let output = command::git_command(project_root)
        .args([GIT_LS_TREE_COMMAND, GIT_HEAD])
        .output();
    let Ok(output) = output else {
        return map;
    };
    if !output.status.success() {
        return map;
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    for line in stdout.lines() {
        // Format: "160000 commit <sha>\t<path>"
        if !line.starts_with(GIT_TREE_SUBMODULE_MODE) {
            continue;
        }
        let Some((meta, path)) = line.split_once('\t') else {
            continue;
        };
        // meta = "160000 commit <sha>"
        let sha = meta
            .rsplit_once(' ')
            .map(|(_, sha)| &sha[..sha.len().min(8)]);
        if let Some(sha) = sha {
            map.insert(path.to_string(), sha.to_string());
        }
    }
    map
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_single_submodule() {
        let content = r#"[submodule "glTF-IBL-Sampler"]
	path = glTF-IBL-Sampler
	url = https://github.com/pcwalton/glTF-IBL-Sampler.git
	branch = lite
"#;
        let entries = parse_gitmodules(content);
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].name, "glTF-IBL-Sampler");
        assert_eq!(entries[0].relative_path, "glTF-IBL-Sampler");
        assert_eq!(
            entries[0].url.as_deref(),
            Some("https://github.com/pcwalton/glTF-IBL-Sampler.git")
        );
        assert_eq!(entries[0].branch.as_deref(), Some("lite"));
    }

    #[test]
    fn parse_multiple_submodules() {
        let content = r#"[submodule "lib-a"]
	path = vendor/lib-a
	url = https://example.com/lib-a.git
[submodule "lib-b"]
	path = vendor/lib-b
	url = https://example.com/lib-b.git
	branch = main
"#;
        let entries = parse_gitmodules(content);
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].name, "lib-a");
        assert_eq!(entries[0].relative_path, "vendor/lib-a");
        assert_eq!(entries[1].name, "lib-b");
        assert_eq!(entries[1].branch.as_deref(), Some("main"));
    }

    #[test]
    fn parse_empty_returns_empty() {
        let entries = parse_gitmodules("");
        assert!(entries.is_empty());
    }
}