Skip to main content

ralph/template/variables/
detect.rs

1//! Purpose: Derive template substitution context from targets, the filesystem,
2//! and git state.
3//!
4//! Responsibilities:
5//! - Convert optional target paths into file/module context.
6//! - Detect git branch information when requested.
7//! - Surface git-detection failures as template warnings.
8//!
9//! Scope:
10//! - Context derivation and git probing only; no validation or substitution.
11//!
12//! Usage:
13//! - Called after template validation determines whether branch context is
14//!   required.
15//!
16//! Invariants/Assumptions:
17//! - `detect_context` keeps legacy behavior by always attempting branch lookup.
18//! - Git probing preserves the `.git/HEAD` fast path and managed-subprocess
19//!   fallback semantics.
20//! - Module derivation behavior remains unchanged.
21
22use std::path::Path;
23
24use anyhow::{Context, Result};
25
26use crate::runutil::{ManagedCommand, TimeoutClass, execute_checked_command};
27
28use super::context::{TemplateContext, TemplateWarning};
29
30/// Detect context from target path and git repository.
31///
32/// Returns the context and any warnings (e.g., git branch detection failures).
33/// Only attempts git branch detection if the template uses {{branch}}.
34pub fn detect_context_with_warnings(
35    target: Option<&str>,
36    repo_root: &Path,
37    needs_branch: bool,
38) -> (TemplateContext, Vec<TemplateWarning>) {
39    let mut warnings = Vec::new();
40    let target_opt = target.map(|s| s.to_string());
41
42    let file = target_opt.as_ref().map(|t| {
43        Path::new(t)
44            .file_name()
45            .map(|n| n.to_string_lossy().to_string())
46            .unwrap_or_else(|| t.clone())
47    });
48
49    let module = target_opt.as_ref().map(|t| derive_module_name(t));
50
51    let branch = if needs_branch {
52        match detect_git_branch(repo_root) {
53            Ok(branch_opt) => branch_opt,
54            Err(e) => {
55                warnings.push(TemplateWarning::GitBranchDetectionFailed {
56                    error: e.to_string(),
57                });
58                None
59            }
60        }
61    } else {
62        None
63    };
64
65    let context = TemplateContext {
66        target: target_opt,
67        file,
68        module,
69        branch,
70    };
71
72    (context, warnings)
73}
74
75/// Detect context from target path and git repository (legacy, ignores warnings).
76pub fn detect_context(target: Option<&str>, repo_root: &Path) -> TemplateContext {
77    let (context, _) = detect_context_with_warnings(target, repo_root, true);
78    context
79}
80
81/// Derive a module name from a file path.
82///
83/// Examples:
84/// - "src/cli/task.rs" -> "cli::task"
85/// - "crates/ralph/src/main.rs" -> "ralph::main"
86/// - "lib/utils.js" -> "utils"
87pub(super) fn derive_module_name(path: &str) -> String {
88    let path_obj = Path::new(path);
89
90    // Get the file stem (filename without extension)
91    let file_stem = path_obj
92        .file_stem()
93        .map(|s| s.to_string_lossy().to_string())
94        .unwrap_or_else(|| path.to_string());
95
96    let mut components: Vec<String> = Vec::new();
97
98    // Walk through parent directories looking for meaningful names
99    for component in path_obj.components() {
100        let comp_str = component.as_os_str().to_string_lossy().to_string();
101
102        // Skip common non-module directories
103        if comp_str == "src"
104            || comp_str == "lib"
105            || comp_str == "bin"
106            || comp_str == "tests"
107            || comp_str == "examples"
108            || comp_str == "crates"
109        {
110            continue;
111        }
112
113        // Skip the filename itself (we use file_stem separately)
114        if comp_str
115            == path_obj
116                .file_name()
117                .map(|n| n.to_string_lossy())
118                .unwrap_or_default()
119        {
120            continue;
121        }
122
123        components.push(comp_str);
124    }
125
126    // If we found meaningful components, combine with file stem
127    if !components.is_empty() {
128        components.push(file_stem);
129        components.join("::")
130    } else {
131        file_stem
132    }
133}
134
135/// Detect the current git branch name.
136fn detect_git_branch(repo_root: &Path) -> Result<Option<String>> {
137    // Try to read from git HEAD
138    let head_path = repo_root.join(".git/HEAD");
139
140    if !head_path.exists() {
141        // Worktrees and submodules may expose `.git` as a file, so fall back to git itself.
142        let mut command = std::process::Command::new("git");
143        command
144            .arg("-c")
145            .arg("core.fsmonitor=false")
146            .arg("rev-parse")
147            .arg("--abbrev-ref")
148            .arg("HEAD")
149            .current_dir(repo_root);
150
151        let output = execute_checked_command(ManagedCommand::new(
152            command,
153            format!("git rev-parse --abbrev-ref HEAD in {}", repo_root.display()),
154            TimeoutClass::MetadataProbe,
155        ))
156        .context("failed to detect template git branch")?;
157
158        let branch = output.stdout_lossy();
159        if branch != "HEAD" {
160            return Ok(Some(branch));
161        }
162        return Ok(None);
163    }
164
165    let head_content = std::fs::read_to_string(&head_path)
166        .with_context(|| format!("failed to read {:?}", head_path))?;
167    let head_ref = head_content.trim();
168
169    // HEAD content is like: "ref: refs/heads/main"
170    if head_ref.starts_with("ref: refs/heads/") {
171        let branch = head_ref
172            .strip_prefix("ref: refs/heads/")
173            .unwrap_or(head_ref)
174            .to_string();
175        Ok(Some(branch))
176    } else if head_ref.len() == 40 && head_ref.chars().all(|c| c.is_ascii_hexdigit()) {
177        // Detached HEAD state (40-character hex commit SHA)
178        Ok(None)
179    } else if head_ref.is_empty() {
180        Err(anyhow::anyhow!("HEAD file is empty"))
181    } else {
182        // Invalid HEAD content
183        Err(anyhow::anyhow!("invalid HEAD content: {}", head_ref))
184    }
185}